125 Commits
Author SHA1 Message Date
justme 675a2bc5b4 Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 16:26:33 +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 76efc15e9a Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/web-build unknown status
2026-10-08 16:13:02 +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 3ee8b6bb3d Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 16:05:42 +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 0fa2f2a26a Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 15:59:32 +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 0e7a8c14fb Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 15:30:16 +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 4a15dba8de Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/web-build unknown status
2026-10-08 06:47:15 +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 8410735377 Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/web-build unknown status
2026-10-08 05:44:20 +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 6310358490 Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 05:41:02 +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 0dc8c6fe34 Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 04:23:16 +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 1f70c8d31e Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-08 04:10:29 +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 0e0b925c8b Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/web-build unknown status
2026-10-08 03:22:20 +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 f01bb8fd63 ci: sync main into dev [CI SKIP] 2026-10-07 20:03:38 +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 96990c6f4e ci: sync main into dev [CI SKIP] 2026-10-07 19:14:58 +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 bd35a1a966 ci: sync main into dev [CI SKIP] 2026-10-07 18:13:20 +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
justme b47e01d944 ci: sync main into dev [CI SKIP] 2026-10-07 17:21:25 +08:00
justme 7ee860cb27 ci: sync main into dev [CI SKIP] 2026-10-07 16:58:59 +08:00
justme 699ed8c411 ci: sync main into dev [CI SKIP] 2026-10-07 16:20:25 +08:00
justme 0012df7b91 ci: sync main into dev [CI SKIP] 2026-10-07 15:08:26 +08:00
justme d5967b6110 ci: sync main into dev [CI SKIP] 2026-10-07 14:57:08 +08:00
justme 450748f3bc ci: sync main into dev [CI SKIP] 2026-10-07 14:18:03 +08:00
justme 5bd53f8880 ci: sync main into dev [CI SKIP] 2026-10-07 03:36:55 +08:00
justme 643f9e1ea3 ci: sync main into dev [CI SKIP] 2026-10-07 03:30:19 +08:00
justme ac0bcd8e1f docs: merge the Fleet docs into backend/fleet/README.md, add TODO.md, fix stale references [CI SKIP] 2026-10-07 03:30:03 +08:00
justme f4aa3f2482 docs: audit the docs, merge the Fleet docs into backend/fleet/README.md and add TODO.md [CI SKIP]
Drop the unimplemented fleet updater design, the stale project-layout sections and broken references, fix the API table, and rename the four Monitor*.cs files left over from the rename.
2026-10-07 03:29:05 +08:00
justme 180e54cb3c ci: sync main into dev [CI SKIP] 2026-10-07 03:12:48 +08:00
justme a19517a57d docs: rewrite the README: scope, trust model, architecture, protocol and build [SKIP TESTS]
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-07 01:51:54 +08:00
justme 83e5120377 refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
2026-10-07 01:49:04 +08:00
justme 17b35223aa ci: sync main into dev [CI SKIP] 2026-10-07 01:46:35 +08:00
justme 4d329f5e16 ci: sync main into dev [CI SKIP] 2026-10-07 01:30:46 +08:00
justme ad17170664 docs: redraw the architecture diagrams and update the README [SKIP TESTS]
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
2026-10-07 01:30:28 +08:00
justme 710aa9e99f ci: sync main into dev [CI SKIP] 2026-10-07 01:28:32 +08:00
justme 71d2866b9c docs: update the architecture [SKIP TESTS] 2026-10-07 01:21:49 +08:00
justme 48664c0ada docs: remove the roadmap and bring the docs in line with the current code [SKIP TESTS]
Probes link to the panel directly, container nodes update through signed
releases, the frontend exists and the ports changed. The roadmap is gone; its
open items live in the README.
2026-10-06 23:27:26 +08:00
justme 198ee556d6 ci: sync main into dev [CI SKIP] 2026-10-06 23:22:52 +08:00
justme da465866d5 fix(web): lift the hovered overview card above its neighbours so the popover is not hidden 2026-10-06 23:22:36 +08:00
justme 3a751a0f89 feat: choose which probes link to which agents from the admin
The panel decides the probe/agent pairs and probes always dial out. A new
fleet.auto_link setting connects every probe to every agent (default); with it
off only the pairs chosen by hand connect, and any pair can be forced on or
off. The Probes page has a link matrix with the switch and per-pair toggles.
2026-10-06 23:19:32 +08:00
justme d69581dcb5 docs: mount the host os-release and fix the agent public host after tunnel enrollment [SKIP TESTS] 2026-10-06 23:11:26 +08:00
justme 8f44e924a4 docs: describe container node deployment and the update flow [SKIP TESTS] 2026-10-06 23:02:16 +08:00
justme 0c39485a78 ci: sync main into dev [CI SKIP] 2026-10-06 22:57:11 +08:00
justme 7a71bf57f9 feat: update controls in the admin, release token scope, build info in CI images
The versions page shows each node's update state with update buttons, API
tokens get a release scope that can only register releases (used by CI), and
the CI image and release builds now pass the tag, commit and build time.
2026-10-06 22:56:56 +08:00
justme 22a98f2eb0 ci: sync main into dev [CI SKIP] 2026-10-06 22:42:20 +08:00
justme 0a66f5e07a feat: signed releases and a host-side updater for container nodes
CI signs the image digest of each agent and probe release; the panel only
stores and forwards it. Nodes write the manifest to their data directory and
a host updater verifies the signature with a local public key, pulls by
digest, restarts the unit and rolls back when it is unhealthy. The panel can
list releases, ask a node to update and show the reported update state.
2026-10-06 22:37:26 +08:00
justme 48072737c9 feat!: probes connect to the backend directly
A probe now links to the backend like an agent (probe.json carries the
server address). The backend sends the agent list as desired state and the
probe reports its platform and build in its own hello. The agent no longer
relays the list or the probe platform. Existing probes must be re-created.
2026-10-06 22:25:16 +08:00
justme 604cbe3123 fix(ci): restore with PublishAot so node images are native, add compose files
Agent and probe images were built with a restore step that lacked
PublishAot, so the publish silently produced a trimmed launcher that looks
for a .dll. Restore now passes PublishAot and the build fails if the binary
is smaller than 2 MB. Also add agent and probe compose files and document
the [CI] marker in AGENTS.md.
2026-10-06 21:43:46 +08:00
justme b242701e9e ci: sync main into dev [CI SKIP] 2026-10-06 21:24:47 +08:00
justme 83f54978d2 feat(web): acrylic theme, node flags and icons, build info
Acrylic cards and topbar over a blurred background, country flags, ISP and
OS icons, per-node version tags, a build info footer and an admin versions
tab. Overview cards drop the sparkline and widen the hover area.
2026-10-06 21:22:04 +08:00
justme 1e5c660d05 feat(backend): report node platform, geo and build info
Agents and probes report OS, arch and build (version, tag, commit, time).
The backend looks up country and ISP of public addresses only, stores them
in node_meta (schema v5) and exposes display info via /api/public/nodes,
/api/public/about and /api/admin/build-info behind site.show_build.
2026-10-06 21:22:04 +08:00
justme 2e470607fa docs: add project agent instructions [SKIP TESTS] 2026-10-06 17:11:07 +08:00
justme a70b89b95c feat(web): show UDP and TCP strips on overview cards
Add 3/6/12 hour ranges with status strips, latency sparklines and a side popover for the hovered interval.
2026-10-06 16:59:17 +08:00
justme a9538eae4a feat(web): add backend switcher and connection status
Split the login page into backend list and credentials, show connection status in the sidebar and top bar, and render dialogs in a portal.
2026-10-06 16:59:17 +08:00
justme 91c63d0cf2 feat(api): add return_session login for cross-origin clients
Return the session token in the login response instead of a cookie, and accept it as a Bearer token without CSRF.
2026-10-06 16:59:16 +08:00
justme 193fc3a95c feat: improve agent management and speed up tests 2026-10-06 15:48:46 +08:00
justme 134c06d605 merge: bring line detail timeline into dev [CI SKIP] 2026-10-06 14:52:48 +08:00
justme d1be70cda2 feat(web): 线路详情页的时间轴、EWMA 平滑和加载进度
- 先选日期,再在连续 48 小时(或 24 小时)的丢包缩略图上拖选区;选区可以跨零点,
  点轴或刻度时离得近的端点贴过去,悬停预览会动的端点;没有数据的时段画成斜纹
- 刻度下方的时长快捷档:30 分钟、1 小时、3 小时、6 小时、12 小时、整天
- 指标卡片平铺,丢包等标明 UDP/TCP;图表按 UDP、TCP、评分分组
- 图表数据刷新时原地更新(不再整张重建),毫秒到 1 秒以上显示成秒
- EWMA 平滑开关(关/轻/中/重),原始数据以淡线垫底
- 数据加载时卡片上显示进度条和占位;useQuery 区分定时刷新和用户触发的加载
- 前端可以在页面里指定后端地址和令牌(配合后端 Monitor__CorsOrigins)
2026-10-06 14:48:58 +08:00
justme 672f6e3dcb fix(ci): emit native apphosts in fastlane images [CI SKIP] 2026-10-05 10:42:34 -07:00
justme 9a375ffd9e fix(build): select AotAnywhere explicitly [CI SKIP] 2026-10-05 10:37:10 -07:00
justme 407c09f6a5 fix(ci): use host networking for native builds [CI SKIP] 2026-10-05 10:30:35 -07:00
justme fffee8937c ci: sync main into dev [CI SKIP] 2026-10-05 10:26:53 -07:00
justme 91485db500 fix(ci): fall back to git diff for empty path lists [CI SKIP] 2026-10-05 10:25:59 -07:00
justme fa34869f18 ci: sync main into dev [CI SKIP] 2026-10-05 10:21:22 -07:00
justme deb5a97802 ci: add dependency-aware main fastlane [CI SKIP] 2026-10-05 10:18:22 -07:00
justme 479f8c25b0 feat(ci): publish agent and probe OCI images [CI SKIP] 2026-10-05 09:33:22 -07:00
justme 2b80bb66d1 ci: use local Gitea endpoint for release uploads 2026-10-05 08:44:19 -07:00
justme 078eacf1cb ci: persist Buildah cache for Linux releases 2026-10-05 08:29:08 -07:00
justme 3db6d5b159 ci: authenticate Zot with API key for justme [CI SKIP] 2026-10-05 22:24:16 +08:00
justme a7a8212c08 ci: push images to public Zot over HTTPS [CI SKIP] 2026-10-05 16:28:15 +08:00
justme 68406843de ci: retry transient Zot registry errors [CI SKIP] 2026-10-04 13:26:09 -07:00
justme fae8c45c90 ci: use bounded runspaces for artifact uploads [CI SKIP] 2026-10-04 13:11:01 -07:00
justme 3bf12ec73d ci: upload release artifacts concurrently [CI SKIP] 2026-10-04 13:05:17 -07:00
justme a9ee4ec34c build: disable PDB debug symbols in releases [CI SKIP] 2026-10-04 12:54:39 -07:00
justme 6b12049df0 ci: drop 32-bit release targets [CI SKIP] 2026-10-04 12:44:32 -07:00
justme d4b4869501 fix(ci): package cross-platform tarballs on Windows [CI SKIP] 2026-10-04 12:38:13 -07:00
justme 92b72d4443 ci: release without blocking on test workflow [CI SKIP] 2026-10-04 12:31:27 -07:00
justme 67b428991f ci: parallelize NativeAOT target builds 2026-10-04 12:29:35 -07:00
justme 55756f7695 ci: cross-publish full NativeAOT matrix from Windows 2026-10-04 12:10:06 -07:00
justme 17a69c1c7a ci: parallelize release components 2026-10-04 11:44:41 -07:00
justme 50d8c07939 ci: gate parallel releases on verified commits 2026-10-05 02:39:21 +08:00
justme 2e549b230d build: use ASP.NET runtime for backend images 2026-10-05 02:24:15 +08:00
justme ace42e5da7 fix(ci): use POST for Gitea release assets 2026-10-05 02:16:14 +08:00
justme ad32afd038 ci: run release builds in parallel 2026-10-05 02:11:29 +08:00
justme 286c9ff7cd ci: publish release assets to Gitea 2026-10-05 02:06:36 +08:00
justme 8aaa574485 fix(ci): make release retryable 2026-10-05 01:21:37 +08:00
justme ce30828ed3 ci: use runner-local Gitea package endpoints 2026-10-05 01:10:17 +08:00
justme a46f45cce3 ci: order multiarch index after builds 2026-10-05 01:05:24 +08:00
justme ce3ba51c02 test: normalize project reference paths across platforms 2026-10-05 00:49:20 +08:00
justme faf1203f0a ci: disable git lfs in clone steps 2026-10-05 00:43:46 +08:00
justme ee422ca366 ci: run verification for push, pull requests and tags 2026-10-05 00:41:29 +08:00
justme c33003c279 ci: restore public Gitea package endpoint 2026-10-04 23:11:37 +08:00
justme 31af31b96f ci: use internal Gitea endpoint 2026-10-04 23:01:59 +08:00
justme 9b6c4e0fd8 ci: skip host OpenAPI generation for arm64 2026-10-04 22:38:26 +08:00
justme ceef543854 ci: verify development branch 2026-10-04 22:20:58 +08:00
justme 299f86dbee ci: configure Woodpecker release pipelines 2026-10-04 22:12:21 +08:00
200 changed files with 13362 additions and 675 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: []
+1 -1
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
+3 -2
View File
@@ -20,7 +20,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
## 信任关系
```text
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
@@ -97,7 +97,8 @@ Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent
| 文档 | 内容 |
| --- | --- |
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
| [`docs/security.md`](docs/security.md) | 零信任设计稿(尚未实现):威胁模型、名册和操作员凭证、监听隐身、extension 授权 |
| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 |
| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 |
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
+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.3.3</Version>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
+1 -1
View File
@@ -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/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
+21 -3
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;
@@ -44,6 +47,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);
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
_config = config;
_identity = identity;
_time = time;
@@ -60,7 +64,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 +84,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 +99,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 +126,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;
@@ -265,6 +276,8 @@ public sealed class AgentApp : IDisposable
{
Server = new Uri(_config.Fleet.Server),
Identity = _identity.Identity,
ServerVerifier = _trust,
Attestation = _update.Attest,
NodeId = _identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
@@ -284,14 +297,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
@@ -45,6 +45,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
@@ -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)
{
@@ -0,0 +1,43 @@
using System.Security.Cryptography;
namespace Fleet.Core;
/// <summary>
/// 节点握手时自述的「我跑的是哪个二进制」:正在运行的可执行文件的 SHA-256,以及(容器节点)宿主机更新器验过的 CI 签名清单和签名。
/// 服务端在节点第一次握手成功的那一刻把哈希绑定到这个节点,之后哈希对不上就不让它继续运行。
/// </summary>
public sealed record BinaryAttestation(string Sha256, string? Manifest, string? Signature);
/// <summary>
/// 正在运行的二进制的哈希。Linux 上读 <c>/proc/self/exe</c>:它指向进程启动时映射的那个映像,磁盘上的文件之后被换掉也不影响,
/// 所以「运行期间被替换、重启后换了别的」的情况下一次握手就会对不上。算一次,之后缓存。
/// </summary>
public static class NodeBinary
{
private static readonly Lazy<string?> s_hash = new(Compute);
/// <summary>小写十六进制;读不到可执行文件时为 null(这样的节点不做绑定)。</summary>
public static string? Sha256Hex => s_hash.Value;
/// <summary>在后台先算好,免得第一次握手等它。</summary>
public static void Prime() => _ = Task.Run(() => s_hash.Value);
private static string? Compute()
{
try
{
string? path = OperatingSystem.IsLinux() && File.Exists("/proc/self/exe") ? "/proc/self/exe" : Environment.ProcessPath;
if (path is null)
{
return null;
}
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16);
return Convert.ToHexStringLower(SHA256.HashData(stream));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
}
@@ -27,4 +27,6 @@ public static class FleetErrors
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
/// <summary>节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 CI 签名的更新清单里。</summary>
public static readonly ErrorCode BinaryMismatch = new("fleet_binary_mismatch");
}
+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; } = [];
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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/access.md 第 8 节)。
/// </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/access.md 第 7 节。
/// 安装命令里带着创世指纹和属主(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 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
/// 不解析 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;
}
}
}
+87 -6
View File
@@ -66,6 +66,15 @@ 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>握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。</summary>
public Func<BinaryAttestation?>? Attestation { get; init; }
}
/// <summary>
@@ -77,6 +86,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 +163,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 +173,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 +181,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 +226,17 @@ public sealed class FleetLink
}
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
private void Attest(AuthParams auth)
{
if (_options.Attestation?.Invoke() is { } attestation)
{
auth.Binary = attestation.Sha256;
auth.Release = attestation.Manifest;
auth.ReleaseSignature = attestation.Signature;
}
}
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 +247,46 @@ 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,
};
Attest(authParams);
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)) };
Attest(authParams);
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 +321,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();
}
}
@@ -53,6 +53,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 +71,21 @@ 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; }
/// <summary>正在运行的二进制的 SHA-256(小写十六进制):服务端在第一次握手时绑定,之后核对。旧节点没有这个字段。</summary>
[JsonPropertyName("binary")]
public string? Binary { get; set; }
/// <summary>安装这个二进制时宿主机更新器验过的 CI 签名清单(原文)和签名(base64):服务端用 CI 公钥验过,才相信它列出的官方哈希。</summary>
[JsonPropertyName("release")]
public string? Release { get; set; }
[JsonPropertyName("release_sig")]
public string? ReleaseSignature { 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,39 @@ 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>
/// <summary>节点握手时自述的二进制(旧节点没有)。</summary>
public sealed record BinaryEvidence(string? Sha256, string? Manifest, string? Signature);
/// <summary>是否让这个节点继续:不放行时 <c>Reason</c> 会原样告诉节点(这是它自己的信息,不是秘密)。</summary>
public sealed record BinaryDecision(bool Accept, string Reason = "");
/// <summary>
/// 服务端对节点二进制的把关:节点第一次握手成功的那一刻绑定它的二进制哈希,之后哈希对不上、又不在 CI 签名的更新清单里,就不让它继续运行。
/// 没有注册实现时不做任何检查。
/// </summary>
public interface IBinaryAttestor
{
ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct);
}
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,12 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
IEnumerable<IStateProvider> stateProviders,
IEnumerable<IInbox> inboxes,
IEnumerable<INodeEventSink> sinks,
FleetRouter router)
FleetRouter router,
IServerIdentity? serverIdentity = null,
IBinaryAttestor? binaryAttestor = null)
{
ServerIdentity = serverIdentity;
BinaryAttestor = binaryAttestor;
Store = store;
Options = options;
Time = time;
@@ -38,6 +42,12 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
internal IFleetStore Store { get; }
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
internal IServerIdentity? ServerIdentity { get; }
/// <summary>节点二进制的把关;没有时不检查。</summary>
internal IBinaryAttestor? BinaryAttestor { get; }
internal FleetServerOptions Options { get; }
internal TimeProvider Time { get; }
@@ -109,7 +109,9 @@ public static class FleetServerExtensions
provider.GetServices<IStateProvider>(),
provider.GetServices<IInbox>(),
provider.GetServices<INodeEventSink>(),
provider.GetRequiredService<FleetRouter>()));
provider.GetRequiredService<FleetRouter>(),
provider.GetService<IServerIdentity>(),
provider.GetService<IBinaryAttestor>()));
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)
{
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
@@ -67,6 +88,18 @@ internal sealed partial class NodeConnection
return false;
}
// 二进制:第一次握手成功的这一刻绑定,之后哈希对不上又不在 CI 签名的更新清单里,就不让它继续运行
if (_host.BinaryAttestor is { } attestor)
{
BinaryDecision decision = await attestor.CheckAsync(node, new BinaryEvidence(auth!.Binary, auth.Release, auth.ReleaseSignature), ct);
if (!decision.Accept)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.BinaryMismatch, decision.Reason)), ct);
await RejectAsync(null, "binary mismatch: " + decision.Reason, ct);
return false;
}
}
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
await SendAsync(
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
@@ -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;
+1649 -66
View File
File diff suppressed because it is too large. Load diff
@@ -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;
}
+6 -2
View File
@@ -27,20 +27,24 @@ public sealed class ProbeApp : IDisposable
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);
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);
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
var options = new FleetLinkOptions
{
Server = new Uri(file.Fleet.Server),
Identity = identity.Identity,
ServerVerifier = trust,
Attestation = update.Attest,
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),
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,
@@ -29,6 +29,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 +52,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,
@@ -6,5 +6,6 @@
<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)
{
@@ -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;
}
}
}
@@ -96,5 +96,34 @@ public sealed class UpdateBridge
}
}
/// <summary>
/// 握手时自述的二进制:正在运行的可执行文件的哈希,加上宿主机更新器验过的、安装这个版本时用的 CI 签名清单(更新器写在只读的 <c>update-status/</c>)。
/// 容器节点以外(没有更新器)没有清单,只有哈希;读不到可执行文件时返回 null(不做绑定)。
/// </summary>
public BinaryAttestation? Attest()
{
if (NodeBinary.Sha256Hex is not { } hash)
{
return null;
}
string? manifest = ReadSmall("installed.manifest", MaxManifestBytes);
string? signature = ReadSmall("installed.sig", MaxSignatureBytes);
return manifest is not null && signature is not null ? new BinaryAttestation(hash, manifest, signature) : new BinaryAttestation(hash, null, null);
}
private string? ReadSmall(string name, int maxBytes)
{
try
{
string path = Path.Combine(Path.GetDirectoryName(_statusFile)!, name);
return File.Exists(path) && new FileInfo(path).Length <= maxBytes ? File.ReadAllText(path, Encoding.UTF8) : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
}
@@ -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));
}
}
@@ -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));
}
}
}
@@ -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));
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);
@@ -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,17 +17,26 @@ 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.MapDelete("/nodes/{id}/binary", ClearBinaryAsync)
.Api("nodes_binary_clear", "Accept the node's current binary: clear its binding so the next handshake binds it again", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetWrite);
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>
@@ -35,15 +44,43 @@ public static class BuildEndpoints
{
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);
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.ReleasePublish);
}
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
/// <summary>
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
/// 旧清单重放由 <c>seq</c> 递增挡住,节点的更新器还会用本机公钥再验一次。
/// </summary>
public static void MapOffer(IEndpointRouteBuilder app)
{
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> OfferReleaseAsync(HttpContext http, ReleaseService releases, 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
|| !ReleaseSignature.Verify(InstallScripts.ReleaseKey(), manifest, signature))
{
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, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct)
{
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(ct);
Dictionary<string, NodeBinding> bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal);
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))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null, 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);
@@ -101,6 +138,17 @@ public static class BuildEndpoints
}
}
private static NodeBinaryDto ToDto(NodeBinding b) =>
new(b.Sha256, b.Signed, b.Seq, DateTimeOffset.FromUnixTimeMilliseconds(b.BoundAtMs), b.Violation, b.ViolationAtMs is { } at ? DateTimeOffset.FromUnixTimeMilliseconds(at) : null);
/// <summary>管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 204,幂等)。</summary>
private static async Task<IResult> ClearBinaryAsync(HttpContext http, BinaryBindingService bindings, string id)
{
http.SetDetail("binary binding cleared");
await bindings.ClearAsync(id, http.RequestAborted);
return TypedResults.NoContent();
}
private static NodeUpdateDto? ToDto(UpdateStatus? status) =>
status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null);
}
@@ -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)
@@ -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/access.md 第 7 节):操作员集合由 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 -2
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,23 @@ public static class ApiModule
RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin);
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync));
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.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);
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,22 +56,50 @@ 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);
/// <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);
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
@@ -64,7 +108,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);
@@ -114,9 +158,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 +185,13 @@ 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>节点二进制的绑定:第一次握手成功时绑定的 SHA-256。<c>Signed</c> 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);<c>Violation</c> 是最近一次不符的原因,下一次握手符合了就清掉。</summary>
public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt);
/// <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, NodeBinaryDto? Binary = 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);
@@ -47,6 +47,8 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(PublishReleaseRequest))]
[JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(NodeBinaryDto))]
[JsonSerializable(typeof(NodeOwnerDto))]
[JsonSerializable(typeof(BuildInfoDto))]
[JsonSerializable(typeof(BackendUpdateDto))]
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
@@ -70,9 +72,22 @@ 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(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);
@@ -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,32 @@
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/access.md 第 15 节)。
/// 角色、面板地址、代码都走命令里的环境变量。
/// </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>脚本内容的 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,15 @@
<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" />
</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>
+43 -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,45 @@ 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);
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 baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
ReleaseOffer? offer = target is null ? null : await releases.GetOrFetchAsync(target.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 +70,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)");
}
@@ -70,7 +70,7 @@ public static class LiveEndpoints
}
}
string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
string client = http.ClientAddress();
if (!limiter.TryAcquire(client))
{
return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client");
@@ -0,0 +1,172 @@
using System.Reflection;
using System.Security.Cryptography;
namespace MoShiro.Backend.Api;
/// <summary>一个可提供的前端文件。<c>Html</c> 的页面带 CSP;其余带最严格的 CSP,免得直接打开 SVG 时执行脚本。</summary>
public sealed record SiteAsset(byte[] Content, string ContentType, string ETag, string CacheControl, bool Html);
/// <summary>
/// 解析的结果:<c>Asset</c> 是要返回的文件;<c>Redirect</c> 是要跳转的地址(只会是站内固定的路径,不来自请求);都为空就是 404。
/// </summary>
public readonly record struct SiteResolution(SiteAsset? Asset, string? Redirect);
/// <summary>
/// 内嵌在 Backend 里的前端:展示端在 <c>/</c>,管理端在 <c>/admin/</c>。
/// <para>
/// 安全做法(线上常见的静态文件穿越和 SPA 回退问题):
/// 文件全部是启动时从程序集资源读进内存的<b>白名单</b>,请求时<b>不碰文件系统</b>,所以没有路径穿越和符号链接的问题;
/// 路径里出现 <c>..</c>、<c>.</c>、反斜杠、<c>%</c>(没解码干净的编码)、控制字符、空段(<c>//</c>)、过长,一律 404,不去「规范化」它;
/// SPA 回退只给没有扩展名的路径,缺失的文件(<c>/assets/x.js</c>)返回 404 而不是 HTML;
/// <c>/api</c> 等后端自己的路径由调用方先排除,所以回退不会吞掉 API 的 404。
/// </para>
/// </summary>
public sealed class StaticSite
{
public const string AdminPrefix = "/admin/";
private const int MaxPathLength = 1024;
private readonly Dictionary<string, SiteAsset> _files = new(StringComparer.Ordinal);
private readonly SiteAsset? _displayIndex;
private readonly SiteAsset? _adminIndex;
/// <param name="files">(站内路径,内容);站内路径以 <c>/</c> 开头,管理端的以 <c>/admin/</c> 开头。</param>
public StaticSite(IEnumerable<(string Path, byte[] Content)> files)
{
foreach ((string path, byte[] content) in files)
{
if (!IsCleanPath(path))
{
continue;
}
_files[path] = Describe(path, content);
}
_displayIndex = _files.GetValueOrDefault("/index.html");
_adminIndex = _files.GetValueOrDefault(AdminPrefix + "index.html");
if (_displayIndex is not null)
{
_files["/"] = _displayIndex;
}
if (_adminIndex is not null)
{
_files[AdminPrefix] = _adminIndex;
}
}
public int Count => _files.Count;
/// <summary>从程序集的嵌入资源读取:<c>www/display/…</c> 对应 <c>/…</c>,<c>www/admin/…</c> 对应 <c>/admin/…</c>。没有嵌入前端(本地没构建)时是空站点。</summary>
public static StaticSite FromAssembly(Assembly assembly)
{
var files = new List<(string, byte[])>();
foreach (string name in assembly.GetManifestResourceNames())
{
string? url = name.StartsWith("www/display/", StringComparison.Ordinal) ? "/" + name["www/display/".Length..]
: name.StartsWith("www/admin/", StringComparison.Ordinal) ? AdminPrefix + name["www/admin/".Length..]
: null;
if (url is null)
{
continue;
}
using Stream stream = assembly.GetManifestResourceStream(name)!;
using var memory = new MemoryStream();
stream.CopyTo(memory);
files.Add((url.Replace('\\', '/'), memory.ToArray()));
}
return new StaticSite(files);
}
public SiteResolution Resolve(string? path)
{
if (!IsCleanPath(path))
{
return default;
}
if (_files.TryGetValue(path, out SiteAsset? exact))
{
return new SiteResolution(exact, null);
}
if (path == "/admin")
{
return _adminIndex is null ? default : new SiteResolution(null, AdminPrefix);
}
// 缺失的文件不回退成 HTML(否则脚本和样式 404 变成「HTML 当脚本」,很难排查,也可能被缓存污染)
string last = path[(path.LastIndexOf('/') + 1)..];
if (last.Contains('.', StringComparison.Ordinal))
{
return default;
}
SiteAsset? index = path.StartsWith(AdminPrefix, StringComparison.Ordinal) ? _adminIndex : _displayIndex;
return index is null ? default : new SiteResolution(index, null);
}
/// <summary>
/// 路径是不是「干净」的:以 <c>/</c> 开头、不过长、没有反斜杠、<c>%</c>、控制字符、<c>.</c> / <c>..</c> 段和中间的空段。
/// 不「规范化」,而是直接拒绝——规范化正是各种穿越绕过(<c>%2e%2e</c>、<c>..%5c</c>、双重编码)的来源。
/// </summary>
public static bool IsCleanPath([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? path)
{
if (string.IsNullOrEmpty(path) || path[0] != '/' || path.Length > MaxPathLength)
{
return false;
}
foreach (char c in path)
{
if (c is '\\' or '%' or '\0' || c < 0x20 || c == 0x7f || c > 0x7e)
{
return false;
}
}
string[] segments = path.Split('/');
for (int i = 1; i < segments.Length; i++)
{
string segment = segments[i];
bool trailing = i == segments.Length - 1;
if (segment is "." or ".." || (segment.Length == 0 && !trailing))
{
return false;
}
}
return true;
}
private static SiteAsset Describe(string path, byte[] content)
{
string extension = Path.GetExtension(path).ToLowerInvariant();
bool html = extension == ".html";
string etag = "\"" + Convert.ToHexStringLower(SHA256.HashData(content))[..32] + "\"";
string cache = html ? "no-cache" : path.Contains("/assets/", StringComparison.Ordinal) ? "public, max-age=31536000, immutable" : "public, max-age=3600";
return new SiteAsset(content, ContentTypeOf(extension), etag, cache, html);
}
private static string ContentTypeOf(string extension) => extension switch
{
".html" => "text/html; charset=utf-8",
".js" or ".mjs" => "text/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".svg" => "image/svg+xml",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
".ico" => "image/x-icon",
".woff2" => "font/woff2",
".woff" => "font/woff",
".json" => "application/json",
".webmanifest" => "application/manifest+json",
".txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
};
}
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace MoShiro.Backend.Api;
/// <summary>把内嵌前端挂到根路径:只响应 GET 和 HEAD,最低优先级,不覆盖任何后端自己的路径。</summary>
public static class StaticSiteEndpoints
{
/// <summary>后端自己的路径前缀:这些路径没有匹配到端点时要得到后端的 404(JSON),不能被前端的 HTML 回退吞掉。</summary>
private static readonly string[] s_reserved = ["/api", "/openapi", "/install", "/healthz"];
// 页面的 CSP:脚本只许同源;样式允许内联(React 的 style 属性);连接允许 https(管理端可以添加别的后端作为数据源)和本机 http
private const string PageCsp =
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; "
+ "connect-src 'self' https: http://localhost:* http://127.0.0.1:*; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
private const string AssetCsp = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
public static IEndpointRouteBuilder MapStaticSite(this IEndpointRouteBuilder app)
{
StaticSite site = StaticSite.FromAssembly(typeof(StaticSite).Assembly);
app.MapMethods("/{**path}", ["GET", "HEAD"], (HttpContext http) => Serve(site, http)).ExcludeFromDescription();
return app;
}
internal static IResult Serve(StaticSite site, HttpContext http)
{
string path = http.Request.Path.Value ?? "/";
if (IsReserved(path))
{
return Results.StatusCode(StatusCodes.Status404NotFound);
}
SiteResolution resolution = site.Resolve(path);
if (resolution.Redirect is { } location)
{
// 固定的站内路径,不拼请求里的任何东西(没有开放重定向,也不依赖 Host)
return Results.Redirect(location, permanent: true);
}
if (resolution.Asset is not { } asset)
{
return Results.StatusCode(StatusCodes.Status404NotFound);
}
IHeaderDictionary headers = http.Response.Headers;
headers.XContentTypeOptions = "nosniff";
headers["Referrer-Policy"] = "no-referrer";
headers.ETag = asset.ETag;
headers.CacheControl = asset.CacheControl;
headers.ContentSecurityPolicy = asset.Html ? PageCsp : AssetCsp;
if (asset.Html)
{
headers.XFrameOptions = "DENY";
headers["Permissions-Policy"] = "publickey-credentials-get=(self), publickey-credentials-create=(self), camera=(), microphone=(), geolocation=()";
}
if (http.Request.Headers.IfNoneMatch.Contains(asset.ETag))
{
return Results.StatusCode(StatusCodes.Status304NotModified);
}
return Results.Bytes(asset.Content, asset.ContentType);
}
private static bool IsReserved(string path)
{
foreach (string prefix in s_reserved)
{
if (path.StartsWith(prefix, StringComparison.Ordinal) && (path.Length == prefix.Length || path[prefix.Length] == '/' || path[prefix.Length] == '.'))
{
return true;
}
}
return false;
}
}
@@ -1,15 +1,26 @@
using Fleet.Core;
using Fleet.Identity;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole);
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null);
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
public sealed record InstallCommands(string Linux, string Macos, string Windows);
/// <summary>
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
/// </summary>
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256);
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
/// <summary>安装脚本里要填的四个值。</summary>
public sealed record InstallTarget(string Role, string Server, string Code, string Image);
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
public sealed class JoinTokenService
{
@@ -32,7 +43,7 @@ public sealed class JoinTokenService
_time = time;
}
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct)
{
var errors = new Dictionary<string, string>();
if (request.Role is not (AgentRole or ProbeRole))
@@ -40,9 +51,9 @@ public sealed class JoinTokenService
errors["role"] = "must be agent or probe";
}
if (request.MaxUses is < 1 or > 1000)
if (request.MaxUses is < 1 or > 100_000)
{
errors["max_uses"] = "must be between 1 and 1000";
errors["max_uses"] = "must be between 1 and 100000";
}
if (request.ExpiresInHours is < 1 or > 24 * 365)
@@ -69,20 +80,57 @@ public sealed class JoinTokenService
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
JoinTokenInfo info = await _store.CreateAsync(
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
// Probe 和 Agent 一样用加入令牌注册;容器节点用部署脚本,Linux 二进制用 install.sh
string shell = request.Role == ProbeRole
? $"sudo sh deploy-node.sh probe --server {baseUrl} --join {plaintext} --name <node-name>"
: $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
string powershell = request.Role == ProbeRole
? $"fleet-enroll --server {baseUrl} --join {plaintext} --name <node-name> --identity-dir <dir>"
: $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
}
/// <summary>
/// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。
/// 里面没有任何私钥,码泄露最多让别人注册出一个节点。
/// </summary>
public static InstallCommands Commands(string baseUrl, string role, string code, InstallPlan plan)
{
string pin = plan.Genesis is null ? "" : $" MOSHIRO_GENESIS='{plan.Genesis}' MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'";
string env = $"MOSHIRO_SERVER='{baseUrl}' MOSHIRO_ROLE='{role}' MOSHIRO_CODE='{code}'{pin}";
string psPin = plan.Genesis is null ? "" : $"$env:MOSHIRO_GENESIS='{plan.Genesis}'; $env:MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'; ";
return new InstallCommands(
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"",
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"",
$"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; "
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
}
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
/// <summary>
/// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。
/// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。
/// </summary>
public async ValueTask<InstallTarget?> ResolveInstallAsync(string code, string? requestBaseUrl, CancellationToken ct)
{
if (code.Length is 0 or > 200)
{
return null;
}
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
if (token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses)
{
return null;
}
string role = RoleOf(token.Kind);
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
string ns = _options.ImageNamespace.Trim('/');
string image = $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}:latest";
string server = BaseUrl(requestBaseUrl);
return IsSafe(server) && IsSafe(image) && IsSafe(code) ? new InstallTarget(role, server, code, image) : null;
}
/// <summary>值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。</summary>
private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%');
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
@@ -0,0 +1,32 @@
using Fleet.Core;
using Fleet.Identity;
namespace MoShiro.Backend.Application;
/// <summary>
/// 节点的属主:创建加入码时选定的 passkey 凭证 id,写进安装命令,节点注册时固定在本地。
/// 属主必须是操作员集合里的凭证;<see cref="BackendOptions.RequireOwner"/> 打开时(默认)必须已经有信任根,并且至少选一把——
/// 不选属主的节点没有人能授权它的敏感操作,也就不该存在。没有明确指定时默认是操作员集合里的全部 passkey。
/// </summary>
public static class OwnerPolicy
{
public static Result<string[]> Resolve(bool required, OperatorSet? set, IReadOnlyList<string>? requested)
{
if (set is null)
{
return required
? ValidationError.Fail<string[]>("controllers", "set up the trust root first (System, Trust): every node needs an owner passkey")
: Result<string[]>.Ok([]);
}
string[] chosen = requested is null ? [.. set.Keys.Select(k => k.CredentialId)] : [.. requested.Distinct(StringComparer.Ordinal)];
if (chosen.Any(c => set.Keys.All(k => k.CredentialId != c)))
{
return ValidationError.Fail<string[]>("controllers", "must be passkeys of the operator set");
}
return required && chosen.Length == 0
? ValidationError.Fail<string[]>("controllers", "pick at least one owner passkey: a node's owner is who its sensitive sessions and data are bound to")
: Result<string[]>.Ok(chosen);
}
}
@@ -0,0 +1,40 @@
using System.Net;
using System.Net.Sockets;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// 自动识别 Agent 的对外地址:Agent 上报本机的公网地址,注册请求的来源地址作为补充(来源是私网或隧道出口时不用)。
/// 管理员手填的(<c>*_manual</c>)永远优先;已经选中的自动地址还在候选里就不换,不会因为网卡顺序变化来回改。
/// </summary>
public static class AgentAddressPolicy
{
/// <summary>返回要写入的 <c>(public_host, ipv6_host)</c>;没有候选的那一项是 null(保持不变)。</summary>
public static (string? PublicHost, string? Ipv6Host) Choose(AgentRecord current, IReadOnlyList<string> reported, string? observed)
{
var v4 = new List<string>();
var v6 = new List<string>();
foreach (string text in reported.Append(observed ?? ""))
{
if (!IPAddress.TryParse(text, out IPAddress? address))
{
continue;
}
if (address.AddressFamily == AddressFamily.InterNetwork && PublicAddress.IsPublicIpv4(address) && !v4.Contains(address.ToString()))
{
v4.Add(address.ToString());
}
else if (address.AddressFamily == AddressFamily.InterNetworkV6 && PublicAddress.IsGlobalIpv6(address) && !v6.Contains(address.ToString()))
{
v6.Add(address.ToString());
}
}
return (current.PublicHostManual ? null : Pick(v4, current.PublicHost), current.Ipv6HostManual ? null : Pick(v6, current.Ipv6Host));
}
private static string? Pick(List<string> candidates, string current) =>
candidates.Count == 0 ? null : candidates.Contains(current) ? current : candidates[0];
}
@@ -24,9 +24,16 @@ public sealed partial class AgentService
private readonly NodeInfoService _nodes;
private readonly LinkService _links;
private readonly TrustService _trust;
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links)
private readonly BinaryBindingService _bindings;
private readonly NodeOwnerService _owners;
public AgentService(BinaryBindingService bindings, NodeOwnerService owners, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
{
_bindings = bindings;
_owners = owners;
_trust = trust;
_links = links;
_nodes = nodes;
_agents = agents;
@@ -69,12 +76,12 @@ public sealed partial class AgentService
errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'";
}
if (patch.PublicHost is not null && !ValidHost(patch.PublicHost, IpVersions.V4))
if (patch.PublicHost is not null && patch.PublicHostAuto != true && !ValidHost(patch.PublicHost, IpVersions.V4))
{
errors["public_host"] = "must be a host name or IP address";
}
if (patch.Ipv6Host is not null && patch.Ipv6Host.Length > 0 && !ValidHost(patch.Ipv6Host, IpVersions.V6))
if (patch.Ipv6Host is not null && patch.Ipv6HostAuto != true && patch.Ipv6Host.Length > 0 && !ValidHost(patch.Ipv6Host, IpVersions.V6))
{
errors["ipv6_host"] = "must be an IPv6 address or host name; empty disables IPv6 collection";
}
@@ -95,7 +102,15 @@ public sealed partial class AgentService
return Result<AgentView>.Fail(error);
}
if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null)
if (patch.PublicHostAuto == true || patch.Ipv6HostAuto == true)
{
// 撤销手填:马上按已经上报的地址重新识别
(string? host, string? v6) = AgentAddressPolicy.Choose(agent, agent.ReportedAddresses ?? [], null);
await _agents.ApplyAutoAddressesAsync(id, host, v6, ct);
agent = await _agents.GetAsync(id, ct) ?? agent;
}
if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null || patch.PublicHostAuto == true || patch.Ipv6HostAuto == true)
{
await AfterAgentSetChangedAsync(ct);
}
@@ -137,6 +152,8 @@ public sealed partial class AgentService
await _gateway.DisconnectAsync(id, "deleted", ct);
await _agents.DeleteAsync(id, ct);
await _nodes.ForgetAsync(id.Value, ct);
await _bindings.ForgetAsync(id.Value, ct);
_owners.Forget(id.Value);
await _links.ForgetNodeAsync(id.Value, ct);
_runtime.Remove(id);
await AfterAgentSetChangedAsync(ct);
@@ -149,7 +166,8 @@ public sealed partial class AgentService
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0;
var result = new ProbeAgentList { Version = version };
foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && links.IsLinked(probeId, a.Id.Value)))
// 没有上报证书公钥的 Agent(旧版本)还不能被 Probe 连接:Probe 需要它来固定证书和派生敲门密钥
foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && a.TlsPublicKey is { Length: > 0 } && links.IsLinked(probeId, a.Id.Value)))
{
result.Agents.Add(new AgentEndpoint
{
@@ -159,10 +177,13 @@ public sealed partial class AgentService
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
TcpPort = agent.ProbeTcpPort,
UdpPorts = [.. agent.ProbeUdpPorts],
Spki = Convert.ToBase64String(agent.TlsSpki),
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
});
}
IReadOnlyList<Fleet.Core.SignedStatementDto> chain = await _trust.ChainAsync(ct);
result.TrustChain = chain.Count == 0 ? null : [.. chain];
return result;
}
@@ -0,0 +1,175 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Server;
namespace MoShiro.Backend.Application;
/// <summary>CI 发布公钥(PEM):验节点握手时带来的签名清单。由 Api 层提供(它就是内嵌在程序里的那一把)。</summary>
public interface IReleaseKey
{
string Pem { get; }
}
/// <summary>
/// 节点二进制的绑定:节点第一次握手成功的那一刻,把它正在运行的二进制的 SHA-256 和这个节点绑在一起;之后哈希对不上,就不让它继续运行。
/// <para>
/// 合法的升级:新哈希出现在一份 <b>CI 签名的清单</b>里(节点握手时带着宿主机更新器验过的清单,这里用内嵌的 CI 公钥再验一遍,
/// 所以不依赖面板有没有收到过这次发布),并且清单的 seq 不低于绑定时的——不许回退到更旧的版本。其余情况(文件被换掉、降级、没人签过的二进制、
/// 手工替换了二进制的非容器节点)都算不符;管理员确认过之后可以清除绑定,下一次握手重新绑定。
/// </para>
/// <para>
/// 边界:哈希是节点自己报的。一个被换进来、故意撒谎的恶意二进制可以照抄原来的哈希;这一层抓的是文件被换、版本被改、降级和起了不对的二进制。
/// 容器节点要堵住撒谎,需要宿主机更新器背书(容器之外),见 docs/access.md。
/// </para>
/// </summary>
public sealed class BinaryBindingService : IBinaryAttestor
{
public const string Enforce = "enforce";
public const string Warn = "warn";
public const string Off = "off";
private readonly INodeBindingStore _store;
private readonly ISettingsStore _settings;
private readonly IReleaseKey _key;
private readonly BackendOptions _options;
private readonly TimeProvider _time;
public BinaryBindingService(INodeBindingStore store, ISettingsStore settings, IReleaseKey key, BackendOptions options, TimeProvider time)
{
_settings = settings;
_store = store;
_key = key;
_options = options;
_time = time;
}
public ValueTask<IReadOnlyList<NodeBinding>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
/// <summary>管理员确认新的二进制是自己要的(比如手工升级了非容器节点):清除绑定,下一次握手重新绑定。</summary>
public ValueTask<bool> ClearAsync(string nodeId, CancellationToken ct) => _store.DeleteAsync(nodeId, ct);
/// <summary>节点删除之后顺手清掉它的绑定。</summary>
public async ValueTask ForgetAsync(string nodeId, CancellationToken ct) => await _store.DeleteAsync(nodeId, ct);
public async ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct)
{
string policy = _options.BinaryPolicy;
if (policy == Off || ReleaseService.ComponentOf(node.Id) is not { } component)
{
return new BinaryDecision(true);
}
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
NodeBinding? binding = await _store.GetAsync(node.Id.Value, ct);
if (evidence.Sha256 is null)
{
// 旧节点不上报:还没绑定过就放行(未绑定);绑定过的不许退回去,否则换个旧版本就绕过了
return binding is null ? new BinaryDecision(true) : await ViolationAsync(binding, "the node stopped reporting its binary hash", policy, now, ct);
}
string hash = evidence.Sha256;
if (!IsHash(hash))
{
return new BinaryDecision(policy != Enforce, "the reported binary hash is malformed");
}
// 节点自己带来的清单(宿主机更新器验过的那份);没有更新器的节点(一键容器安装、二进制安装)没有,就用面板自己手里的:
// 面板登记它们时已经验过 CI 签名,这里再验一遍。只有节点自带的清单才能因为「不在清单里」而被拒绝,面板手里的只用来把绑定标成已签名。
Official? official = VerifyManifest(evidence.Manifest, evidence.Signature, component);
bool carried = official is not null;
official ??= await StoredOfficialAsync(component, ct);
bool listed = official is not null && official.Binaries.Contains(hash);
if (binding is null)
{
if (carried && !listed)
{
// 带着一份有效的签名清单,自己的哈希却不在里面:它不是这份清单对应的官方二进制
return new BinaryDecision(policy != Enforce, "this binary is not in the CI-signed manifest it was installed from");
}
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, listed ? official!.Seq : 0, listed, now, null, null), ct);
return new BinaryDecision(true);
}
if (binding.Sha256 == hash)
{
if (listed && (official!.Seq > binding.Seq || !binding.Signed || binding.Violation is not null))
{
await _store.UpsertAsync(binding with { Seq = Math.Max(binding.Seq, official!.Seq), Signed = true, Violation = null, ViolationAtMs = null }, ct);
}
else if (binding.Violation is not null)
{
await _store.UpsertAsync(binding with { Violation = null, ViolationAtMs = null }, ct);
}
return new BinaryDecision(true);
}
// 哈希变了:必须是一份不低于绑定时 seq 的 CI 签名清单里的官方二进制(合法升级),否则不符
if (listed && official!.Seq >= binding.Seq)
{
await _store.UpsertAsync(new NodeBinding(node.Id.Value, hash, official.Seq, true, now, null, null), ct);
return new BinaryDecision(true);
}
return await ViolationAsync(binding, $"the running binary changed (bound {Short(binding.Sha256)}, now {Short(hash)}) and is not in a newer CI-signed manifest", policy, now, ct);
}
private async ValueTask<BinaryDecision> ViolationAsync(NodeBinding binding, string reason, string policy, long now, CancellationToken ct)
{
await _store.UpsertAsync(binding with { Violation = reason, ViolationAtMs = now }, ct);
return new BinaryDecision(policy != Enforce, reason);
}
private sealed record Official(long Seq, HashSet<string> Binaries);
/// <summary>用内嵌的 CI 公钥验清单;验过且是这个组件的才返回它列出的官方二进制哈希。</summary>
private async ValueTask<Official?> StoredOfficialAsync(string component, CancellationToken ct) =>
VerifyManifest(await _settings.GetAsync(ReleaseService.Key(component, "manifest"), ct), await _settings.GetAsync(ReleaseService.Key(component, "signature"), ct), component);
private Official? VerifyManifest(string? manifestText, string? signatureText, string component)
{
if (manifestText is not { Length: > 0 and <= ReleaseService.MaxManifestBytes } manifest
|| signatureText is not { Length: > 0 and <= ReleaseService.MaxSignatureBytes } signature
|| !ReleaseSignature.Verify(_key.Pem, manifest, signature))
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(manifest);
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("component", out JsonElement c) || c.GetString() != component
|| !root.TryGetProperty("seq", out JsonElement s) || !s.TryGetInt64(out long seq) || seq <= 0)
{
return null;
}
var binaries = new HashSet<string>(StringComparer.Ordinal);
if (root.TryGetProperty("binaries", out JsonElement list) && list.ValueKind == JsonValueKind.String)
{
foreach (string item in (list.GetString() ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (IsHash(item))
{
binaries.Add(item);
}
}
}
return new Official(seq, binaries);
}
catch (JsonException)
{
return null;
}
}
private static bool IsHash(string value) => value.Length == 64 && value.All(ch => ch is (>= '0' and <= '9') or (>= 'a' and <= 'f'));
private static string Short(string hash) => hash.Length > 12 ? hash[..12] : hash;
}
@@ -40,9 +40,16 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
}
}
// public_host 未提供时,取注册请求的来源 IP
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost : context.RemoteIp ?? "";
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
: System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : "";
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
if (await _agents.GetAsync(node.Id, ct) is { } enrolled)
{
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
await _agents.ApplyAutoAddressesAsync(node.Id, null, observedV6, ct);
}
if (node.Status == NodeStatus.Active)
{
await _service.AfterAgentSetChangedAsync(ct);
@@ -62,9 +69,11 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
private readonly ReleaseService _releases;
private readonly ScheduleService _schedule;
private readonly IProbeStore _probes;
private readonly NodeOwnerService _owners;
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes)
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners)
{
_owners = owners;
_releases = releases;
_schedule = schedule;
_probes = probes;
@@ -141,6 +150,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
{
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
_owners.Report(id.Value, meta?.Owners);
if (meta?.Ipv6 is { } ipv6)
{
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
@@ -202,12 +212,19 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
}
}
// Agent 上报 TLS 证书的公钥:指纹由面板自己算,不信节点报的指纹
byte[]? tlsKey = null;
byte[]? spki = null;
if (meta.TlsSpki is not null)
if (meta.TlsPublicKey is not null)
{
try
{
spki = Convert.FromBase64String(meta.TlsSpki);
byte[] candidate = Convert.FromBase64String(meta.TlsPublicKey);
if (Fleet.Identity.NodeIdentity.IsValidPublicKey(candidate))
{
tlsKey = candidate;
spki = Fleet.Identity.NodeIdentity.Fingerprint(candidate);
}
}
catch (FormatException)
{
@@ -215,18 +232,26 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
}
}
_owners.Report(id.Value, meta.Owners);
AgentRecord? before = await _agents.GetAsync(id, ct);
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json), ct);
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
// 端口或证书指纹变了,Probe 需要新的列表
// 自动识别对外地址:Agent 上报的公网地址;手填的不动
if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
{
(string? publicHost, string? ipv6Host) = AgentAddressPolicy.Choose(reported, meta.Addresses, null);
await _agents.ApplyAutoAddressesAsync(id, publicHost, ipv6Host, ct);
}
// 端口、证书指纹或对外地址变了,Probe 需要新的列表
AgentRecord? after = await _agents.GetAsync(id, ct);
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
await _releases.OfferToNodeAsync(id, ct);
await _schedule.SendToNodeAsync(id, ct);
if (before is not null && after is not null
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported)
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || !(before.TlsPublicKey ?? []).AsSpan().SequenceEqual(after.TlsPublicKey ?? []) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported || before.PublicHost != after.PublicHost || before.Ipv6Host != after.Ipv6Host)
&& after.Status == NodeStatus.Active)
{
await _service.AfterAgentSetChangedAsync(ct);
@@ -0,0 +1,68 @@
using System.Collections.Concurrent;
namespace MoShiro.Backend.Application;
/// <summary>节点的一个属主:它报上来的 passkey 凭证 id,以及面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
public sealed record NodeOwner(string CredentialId, string? PasskeyName, string? Username);
/// <summary>
/// 节点的属主:Agent 和 Probe 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。
/// <b>属主的强制在节点本地</b>(没有属主的节点不运行,见 <c>OwnerGuard</c>),面板这边只是顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上——
/// 以后给节点更多的接口和操作(extension 会话、加密内容)时,据此判断谁有资格用。上报的只是自述,真正的授权由节点自己对 passkey 签名的校验把关。
/// 在内存里:节点每次连上来都会重新报。
/// </summary>
public sealed class NodeOwnerService
{
private const int MaxOwners = 16;
private const int MaxCredentialIdLength = 256;
private readonly ConcurrentDictionary<string, string[]> _reported = new(StringComparer.Ordinal);
private readonly IAuthStore _auth;
public NodeOwnerService(IAuthStore auth)
{
_auth = auth;
}
public void Report(string nodeId, IReadOnlyList<string>? credentialIds)
{
string[] clean = credentialIds is null ? [] : [.. credentialIds.Where(id => id.Length is > 0 and <= MaxCredentialIdLength).Distinct(StringComparer.Ordinal).Take(MaxOwners)];
if (clean.Length == 0)
{
_reported.TryRemove(nodeId, out _);
}
else
{
_reported[nodeId] = clean;
}
}
public void Forget(string nodeId) => _reported.TryRemove(nodeId, out _);
/// <summary>每个节点的属主(只含报过的节点)。</summary>
public async ValueTask<IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>>> ListAsync(CancellationToken ct)
{
var result = new Dictionary<string, IReadOnlyList<NodeOwner>>(StringComparer.Ordinal);
var users = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach ((string nodeId, string[] ids) in _reported)
{
var owners = new List<NodeOwner>();
foreach (string id in ids)
{
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(id, ct);
string? username = null;
if (passkey is not null && !users.TryGetValue(passkey.UserId, out username))
{
username = (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
users[passkey.UserId] = username;
}
owners.Add(new NodeOwner(id, passkey?.Name, username));
}
result[nodeId] = owners;
}
return result;
}
}
@@ -29,8 +29,13 @@ public sealed partial class ProbeService
private readonly NodeInfoService _nodes;
private readonly LinkService _links;
public ProbeService(IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
private readonly BinaryBindingService _bindings;
private readonly NodeOwnerService _owners;
public ProbeService(BinaryBindingService bindings, NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
{
_bindings = bindings;
_owners = owners;
_links = links;
_nodes = nodes;
_probes = probes;
@@ -94,6 +99,8 @@ public sealed partial class ProbeService
if (deleted)
{
await _nodes.ForgetAsync(id, ct);
await _bindings.ForgetAsync(id, ct);
_owners.Forget(id);
await _links.ForgetNodeAsync(id, ct);
_gateway.NotifyAllTaskSetsChanged();
}
@@ -35,6 +35,17 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<IngestService>();
services.AddSingleton<WindowComputer>();
services.AddSingleton<AuthService>();
services.AddSingleton<PasskeyService>();
services.AddSingleton<AuditService>();
services.AddSingleton<ServerIdentityService>();
services.AddSingleton<SetupService>();
services.AddSingleton<ReleaseFeed>();
services.AddSingleton<BinaryBindingService>();
services.AddSingleton<NodeOwnerService>();
services.AddSingleton<Fleet.Server.IBinaryAttestor>(sp => sp.GetRequiredService<BinaryBindingService>());
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
services.AddSingleton<TrustService>();
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
services.AddSingleton<SettingsService>();
services.AddSingleton<ReleaseService>();
services.AddSingleton<ScheduleService>();
@@ -0,0 +1,60 @@
using Microsoft.Extensions.Logging;
namespace MoShiro.Backend.Application;
/// <summary>
/// 审计日志里的一行:谁(<see cref="Actor"/>,用户名或 <c>key:令牌名</c>)、怎么登录的(<see cref="Method"/>)、做了什么(<see cref="Action"/>,
/// 管理接口的 operationId,或 <c>auth.login</c> 这类登录事件)、对谁(<see cref="Target"/>)、结果如何。
/// </summary>
public sealed record AuditRecord(long Id, long AtMs, string? UserId, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success);
public sealed record AuditQuery(long? BeforeId, int Limit, string? Actor, string? Action);
/// <summary>审计日志只追加:没有修改和单条删除,只按保留期整体清理。</summary>
public sealed partial class AuditService
{
/// <summary>保留一年。审计记录的是谁动过信任相关的东西,比监测数据留得久。</summary>
public const int RetentionDays = 365;
private readonly IAuditStore _store;
private readonly TimeProvider _time;
private readonly ILogger<AuditService> _logger;
public AuditService(IAuditStore store, TimeProvider time, ILogger<AuditService> logger)
{
_store = store;
_time = time;
_logger = logger;
}
/// <summary>记一条。写不进去只记日志,不能因为审计失败而让已经完成的操作报错。</summary>
public async ValueTask RecordAsync(Principal principal, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default) =>
await RecordAsync(principal.UserId, principal.Label, MethodOf(principal), action, target, detail, remote, success, ct);
public async ValueTask RecordAsync(string? userId, string actor, string method, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default)
{
try
{
await _store.AddAsync(new AuditRecord(0, _time.GetUtcNow().ToUnixTimeMilliseconds(), userId, actor, method, action, Clip(target, 200), Clip(detail, 500), remote, success), ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogFailed(ex, action);
}
}
public ValueTask<IReadOnlyList<AuditRecord>> ListAsync(AuditQuery query, CancellationToken ct) => _store.ListAsync(query, ct);
public ValueTask<int> PruneAsync(long nowMs, CancellationToken ct) => _store.DeleteOlderThanAsync(nowMs - (RetentionDays * 86_400_000L), ct);
/// <summary>登录方式:密码、passkey,或 API key。</summary>
public static string MethodOf(Principal principal) =>
principal.Kind == PrincipalKind.Anonymous ? "anonymous"
: principal.Kind == PrincipalKind.Token && principal.UserId is null ? "key"
: principal.AuthMethod.Length > 0 ? principal.AuthMethod : AuthMethods.Password;
private static string? Clip(string? text, int max) => text is { } t && t.Length > max ? t[..max] : text;
[LoggerMessage(Level = LogLevel.Warning, Message = "could not write the audit record for {Action}")]
private partial void LogFailed(Exception exception, string action);
}
@@ -7,24 +7,25 @@ public enum PrincipalKind
Token,
}
/// <summary>访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。</summary>
public enum ApiScope
/// <summary>
/// 已认证的主体:会话或 API key,带着它拥有的能力集合。授权只看 <see cref="Has"/>。
/// </summary>
public sealed record Principal(PrincipalKind Kind, IReadOnlySet<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId)
{
None = 0,
Release = 1,
Read = 2,
Admin = 3,
}
/// <summary>会话是怎么登录的(<see cref="AuthMethods"/>):登录方式只用于审计和显示;敏感操作靠提权窗口(触碰 passkey)。API key 为空。</summary>
public string AuthMethod { get; init; } = "";
public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId)
{
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, ApiScope.None, null, null, null, null);
/// <summary>会话的标识(令牌哈希的十六进制):API key 为空。</summary>
public string? SessionId { get; init; }
public bool CanRead => Scope >= ApiScope.Read;
/// <summary>审计和归属里显示的名字:用户名,或者 <c>key:令牌名</c>。</summary>
public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous";
public bool IsAdmin => Scope >= ApiScope.Admin;
public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet<string>(), null, null, null, null);
public bool CanPublishRelease => Scope is ApiScope.Release or ApiScope.Admin;
public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability);
public static IReadOnlySet<string> SetOf(IEnumerable<string> capabilities) => new HashSet<string>(capabilities, StringComparer.Ordinal);
}
public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username);
@@ -37,11 +37,22 @@ public sealed partial class AuthService
return Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
}
return await CreateSessionAsync(user, AuthMethods.Password, ct);
}
/// <summary>已经确认了用户身份(例如 passkey 断言通过)之后建立会话。</summary>
public async ValueTask<Result<LoginResult>> CreateSessionForAsync(string userId, string method, CancellationToken ct) =>
await _store.GetUserAsync(userId, ct) is { } user
? await CreateSessionAsync(user, method, ct)
: Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "invalid credentials");
private async ValueTask<Result<LoginResult>> CreateSessionAsync(UserRecord user, string method, CancellationToken ct)
{
byte[] token = RandomNumberGenerator.GetBytes(32);
byte[] csrf = RandomNumberGenerator.GetBytes(32);
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
long expires = now + (long)_options.SessionLifetime.TotalMilliseconds;
await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires), ct);
await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires, method), ct);
await _store.DeleteExpiredSessionsAsync(now, ct);
return Result<LoginResult>.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username));
}
@@ -60,25 +71,24 @@ public sealed partial class AuthService
}
UserRecord? user = await _store.GetUserAsync(session.UserId, ct);
return user is null ? null : new Principal(PrincipalKind.Session, ApiScope.Admin, user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null);
return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) };
}
public async ValueTask<Principal?> AuthenticateTokenAsync(string bearer, CancellationToken ct)
{
ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct);
if (token is null || token.RevokedAtMs is not null)
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (token is null || token.RevokedAtMs is not null || (token.ExpiresAtMs is { } expires && expires <= now))
{
return null;
}
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs)
{
await _store.TouchApiTokenAsync(token.Id, now, ct);
}
ApiScope scope = token.Scope switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => ApiScope.Read };
return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id);
return new Principal(PrincipalKind.Token, Principal.SetOf(token.Capabilities), null, token.Name, null, token.Id);
}
public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct)
@@ -128,20 +138,48 @@ public sealed partial class AuthService
public ValueTask<IReadOnlyList<UserRecord>> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct);
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, string scope, CancellationToken ct)
/// <summary>
/// 创建 API key:能力必须都在目录里,并且是创建者自己拥有能力的子集(不能越权);写蕴含读,结果稳定排序。
/// <paramref name="expiresInDays"/> 为空表示不过期。
/// </summary>
public async ValueTask<Result<CreatedApiToken>> CreateApiTokenAsync(string name, IReadOnlyList<string> capabilities, int? expiresInDays, Principal creator, CancellationToken ct)
{
var errors = new Dictionary<string, string>();
if (name.Length is 0 or > 64)
{
return ValidationError.Fail<CreatedApiToken>("name", "must be 1-64 characters");
errors["name"] = "must be 1-64 characters";
}
if (scope is not ("read" or "admin" or "release"))
string[] unknown = [.. capabilities.Where(c => !CapabilityCatalog.IsKnown(c))];
if (capabilities.Count == 0)
{
return ValidationError.Fail<CreatedApiToken>("scope", "must be read, admin or release");
errors["capabilities"] = "choose at least one capability";
}
else if (unknown.Length > 0)
{
errors["capabilities"] = "unknown capability: " + string.Join(", ", unknown);
}
if (expiresInDays is < 1 or > 3650)
{
errors["expires_in_days"] = "must be between 1 and 3650";
}
IReadOnlyList<string> effective = CapabilityCatalog.Expand(capabilities.Where(CapabilityCatalog.IsKnown));
string[] beyond = [.. effective.Where(c => !creator.Has(c))];
if (beyond.Length > 0)
{
errors["capabilities"] = "you cannot grant what you do not have: " + string.Join(", ", beyond);
}
if (errors.Count > 0)
{
return ValidationError.Fail<CreatedApiToken>(errors);
}
string plaintext = TokenHash.NewToken("mos");
var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, scope, TokenHash.Sha256(plaintext), _time.GetUtcNow().ToUnixTimeMilliseconds(), null, null);
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, effective, TokenHash.Sha256(plaintext), now, null, expiresInDays is { } days ? now + (days * 86_400_000L) : null, null);
await _store.CreateApiTokenAsync(record, ct);
return Result<CreatedApiToken>.Ok(new CreatedApiToken(record, plaintext));
}
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff. Show more