46 Commits
Author SHA1 Message Date
justme 1b052dcaef backend: confirm signed channel updates (#16) [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
Publish signed, channel-matched Backend update offers and host IPv6 diagnostics.
2026-10-07 05:05:08 +00:00
justme 33b9782a1d ci: tolerate legacy Backend release webhook [CI]
ci/woodpecker/pr/verify Pipeline was successful
ci/woodpecker/pr/web-build Pipeline was successful
2026-10-07 13:00:35 +08:00
justme 84f9d211bc backend: require confirmation for signed channel updates [CI]
ci/woodpecker/pr/verify Pipeline failed
ci/woodpecker/pr/web-build unknown status
2026-10-07 12:57:20 +08:00
justme 7160de2a36 shared: merge IPv4 and IPv6 collection (#15) [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
Preserve independent dual-stack measurement paths; PR checks passed.
2026-10-07 03:48:39 +00:00
justme 0eda0600cc shared: add independent IPv4 and IPv6 collection [CI]
ci/woodpecker/pr/verify Pipeline was successful
ci/woodpecker/pr/web-build Pipeline was successful
2026-10-07 11:44:01 +08:00
justme c974a7dddb docs: merge the Fleet docs into backend/fleet/README.md, add TODO.md, unify the Backend compose, fix stale references [CI SKIP] 2026-10-07 03:36:34 +08:00
justme a286a453f4 fix: let the update helper write the status volume owned by the app user [CI SKIP]
The status helper runs as root with all capabilities dropped, so it could not write a volume owned by uid 1654 (mktemp: Permission denied) and the volume updater failed on rootful Podman. Add DAC_OVERRIDE only.
2026-10-07 02:58:34 +08:00
justme 7613f67fed fix: create the update directory for fresh named volumes [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ConfigStore.Write only created the config root, so a new volume without update/ made update offers fail with IO_PathNotFound. Create the parent directory on write and ship an empty update/ in the node images.
2026-10-07 02:51:39 +08:00
justme ef75c9f510 fix: API tokens use the mos_ prefix [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 02:31:32 +08:00
justme 18a6898a27 fix: build the Backend image as glibc so it can load libssl [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
The musl AotAnywhere output is a fully static executable, which cannot dlopen libssl: the Backend exited with 'No usable version of libssl was found' on start. Use linux-x64/arm64 and the Debian runtime-deps base.
2026-10-07 02:21:31 +08:00
geruipu d3fadc0bf8 fix: support rootless ICMP and verify SELinux deployment [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 02:06:34 +08:00
justme 2e146d4da7 refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
2026-10-07 02:03:25 +08:00
geruipu 27ac7fb787 feat: share named volumes across Docker and rootless Podman updates [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 01:41:15 +08:00
justme 90b3ca4dad docs: update the architecture, README and related docs, drop the roadmap [SKIP TESTS] 2026-10-07 01:30:41 +08:00
geruipu 103ef27ed3 feat: use Alpine AOT images, static SQLite and Podman host metrics [CI] 2026-10-07 01:03:31 +08:00
justme 6e945657c1 feat: choose probe/agent links from the admin, fix the overview popover [CI]
The panel decides which probes link to which agents (probes dial out): an
auto-link setting plus per-pair manual overrides with a link matrix in the
admin, and the hovered overview card is lifted above its neighbours.
2026-10-06 23:22:45 +08:00
justme d449e50791 feat: update controls in the admin, release token scope, build info in CI images [CI]
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:57:04 +08:00
justme 8dd3209a65 feat!: probes link to the panel; signed release updates for container nodes [CI]
Probes connect to the panel directly and get the agent list as desired state.
CI signs the image digest of agent and probe releases, the panel forwards the
manifest, and a host updater verifies it with a local public key, pulls by
digest and rolls back when the new version is unhealthy.
2026-10-06 22:42:12 +08:00
justme c645851c0e fix(ci): build native node images and add compose files [CI]
Restore with PublishAot so agent and probe images contain NativeAOT
binaries instead of a launcher that needs a .dll, fail the build when the
binary is too small, add agent and probe compose files, and document the
[CI] marker in AGENTS.md.
2026-10-06 21:43:57 +08:00
justme e06462f033 ci: build main images [CI] 2026-10-06 21:25:53 +08:00
justme 14b0de3544 feat: node info, build info and acrylic web theme
Squash of recent dev work: return_session login, backend switcher, line
detail timeline, overview strips, node platform/country/ISP info with flags
and icons, build info (version, tag, time) behind site.show_build, and the
acrylic display theme.
2026-10-06 21:24:25 +08:00
justme 10634bdd80 docs: add project agent instructions [SKIP TESTS]
Merge PR #14.
2026-10-06 09:14:54 +00:00
justme f8175f5734 docs: add project agent instructions [SKIP TESTS] 2026-10-06 17:13:08 +08:00
justme a2a0eee46a docs: explain development and CI workflow [SKIP TESTS]
Merge PR #13.
2026-10-06 08:46:12 +00:00
justme 929882cb04 docs: explain development and CI workflow [SKIP TESTS] 2026-10-06 16:44:27 +08:00
justme 2def8e69f9 ci: publish amd64 dev images to Zot [CI]
Merge PR #12. Publish development images separately from release images.
2026-10-06 08:28:53 +00:00
justme efa17b4485 ci: use SKIP TESTS marker for backend tests [SKIP TESTS] 2026-10-06 16:26:57 +08:00
justme 56d80e8c80 ci: build web after tests and allow skipping tests [CI SKIP] 2026-10-06 16:23:13 +08:00
justme 1da8d083c1 ci: skip backend tests only with CI SKIP suffix [CI SKIP] 2026-10-06 16:21:04 +08:00
justme e67d3294a2 ci: allow skipping verify with CI SKIP suffix [CI SKIP] 2026-10-06 16:18:59 +08:00
justme 383afa777b ci: publish main amd64 images to dev Zot [CI SKIP] 2026-10-06 16:14:50 +08:00
justme da0a0e3443 ci: parallelize opt-in main amd64 builds [CI]
Merge PR #11. Build Backend, Agent, and Probe concurrently on main.
2026-10-06 08:04:13 +00:00
justme 19c6cab104 ci: run main amd64 image builds in parallel [CI SKIP] 2026-10-06 15:59:54 +08:00
justme dd6ac4258d ci: opt in to main amd64 builds [CI]
Merge PR #10. Main Linux amd64 images are built only when explicitly marked.
2026-10-06 07:53:57 +00:00
justme 46499b10b6 ci: build amd64 on opt-in main commits [CI SKIP] 2026-10-06 15:40:35 +08:00
justme 0f19d5047d ci: make main fastlane manual only [CI SKIP] 2026-10-06 15:18:12 +08:00
justme 5cb64d2436 docs: clarify no-op main fastlane for docs
Describe the successful no-op when only documentation changes.

Co-authored-by: JustMe <mr.toing@outlook.com>
2026-10-05 18:58:35 +00:00
justme 9dcb95ccd0 fix(ci): trigger fastlane on merged main PRs
Handle merged Gitea PRs as well as direct pushes to main.

Co-authored-by: JustMe <mr.toing@outlook.com>
2026-10-05 18:42:39 +00:00
justme 4bde12b493 fix(ci): trigger fastlane on merged main PRs
Run the Linux amd64 fast-lane after a PR is merged to main, while leaving unmerged PRs as no-ops.

Co-authored-by: JustMe <mr.toing@outlook.com>
2026-10-05 18:35:24 +00:00
justme c4f1fd9e89 docs: clarify main fastlane merge trigger
Explain that feature PRs update main images after merging.

Co-authored-by: JustMe <mr.toing@outlook.com>
2026-10-05 18:08:49 +00:00
justme ee24593b2a fix(ci): emit native apphosts in fastlane images
Force native apphost generation and validate fastlane output files.

Co-authored-by: JustMe <mr.toing@outlook.com>
2026-10-05 17:43:22 +00:00
justme 7cc3afbd9b fix(build): use native NativeAOT SDK in Linux fast lane
Only use the StuDev AotAnywhere SDK when explicitly enabled by Windows cross-platform builds.

Co-authored-by: JustMe <mr.toing@outlook.com>
2026-10-05 17:37:50 +00:00
justme 75d6bd5540 fix(ci): use host networking for native builds
Run NativeAOT Buildah steps with host networking on the Linux agent.

Co-authored-by: JustMe <mr.toing@outlook.com>
2026-10-05 17:31:24 +00:00
justme d6b919bc42 fix(ci): handle empty changed-file lists
Use a Git diff fallback when Woodpecker provides no changed-file list.

Co-authored-by: JustMe <mr.toing@outlook.com>
2026-10-05 17:27:35 +00:00
justme dcf096431d ci: add dependency-aware main fastlane
Add Linux amd64 fast-lane OCI builds selected by project dependencies.

Co-authored-by: JustMe <mr.toing@outlook.com>
2026-10-05 17:22:26 +00:00
justme deceeb19f3 feat: add cross-platform release pipeline [CI SKIP] 2026-10-04 14:01:26 -07:00
378 changed files with 13546 additions and 3014 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
ci/build-main-amd64.sh text eol=lf
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
+17 -1
View File
@@ -17,11 +17,27 @@ depends_on:
- windows-release - windows-release
steps: steps:
- name: publish-agent-probe-oci
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
commands:
- bash ci/release-node-images.sh
depends_on: []
- name: publish-gitea-release - name: publish-gitea-release
image: bash image: bash
environment: environment:
GITEA_BASE: http://192.168.2.162:3100 GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN: GITEA_TOKEN:
from_secret: gitea_token from_secret: gitea_token
commands: commands:
- bash ci/publish-gitea-release.sh - bash ci/publish-gitea-release.sh
depends_on:
- publish-agent-probe-oci
+5
View File
@@ -47,6 +47,11 @@ steps:
ZOT_USERNAME: justme ZOT_USERNAME: justme
ZOT_SECRET: ZOT_SECRET:
from_secret: zot_secret from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands: commands:
- bash ci/index-backend-image.sh - bash ci/index-backend-image.sh
depends_on: depends_on:
+68
View File
@@ -0,0 +1,68 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
when:
- event: push
branch: main
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
concurrency: 1
steps:
- name: build-linux-amd64-backend
image: bash
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
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: []
- name: build-linux-amd64-agent
image: bash
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
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: []
- name: build-linux-amd64-probe
image: bash
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
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: []
+8 -12
View File
@@ -9,22 +9,18 @@ clone:
lfs: false lfs: false
when: when:
- event: [push, pull_request] - event: pull_request
- event: push
branch: dev
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
steps: steps:
- name: backend-tests - name: backend-tests
image: bash image: bash
# [SKIP TESTS] omits Backend tests only; web-build remains a dependent workflow.
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
environment: environment:
NUGET_PACKAGES: /home/justme/.nuget/packages NUGET_PACKAGES: /home/justme/.nuget/packages
commands: commands:
- cd backend && dotnet test --solution Monitor.slnx -c Release - cd backend && dotnet test --solution MoShiro.slnx -c Release
- name: web-build
image: bash
environment:
NPM_CONFIG_CACHE: /home/justme/.npm
commands:
- npm ci --prefix web --prefer-offline
- npm run build --prefix web
- npm run lint:css --prefix web
depends_on: []
+27
View File
@@ -0,0 +1,27 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
when:
- event: pull_request
- event: push
branch: dev
depends_on:
- verify
steps:
- name: web-build
image: bash
environment:
NPM_CONFIG_CACHE: /home/justme/.npm
commands:
- npm ci --prefix web --prefer-offline
- npm run build --prefix web
- npm run lint:css --prefix web
+43
View File
@@ -0,0 +1,43 @@
# Agent Instructions
## Project
MoShiro is a self-hosted network path quality monitor. Backend coordinates Agents and Probes, stores measurements, and serves the REST/SSE API. `web/display` is the read-only overview; `web/admin` manages nodes, tests, and settings.
## Branch and workspace safety
- 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.
- 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
- `backend/src/MoShiro.Backend/`: service entry point and composition.
- `backend/src/MoShiro.Backend.Api/`: REST endpoints, contracts, and OpenAPI.
- `backend/agent/`, `backend/probe/`: node applications and their READMEs.
- `backend/fleet/`, `backend/shared/`: reusable node framework, measurement engine, protocols, and data formats.
- `web/display/`, `web/admin/`: Vite applications; `web/shared/`: API client, generated types, charts, and shared UI.
- `packaging/`, `scripts/`, `.woodpecker/`, `docs/`: container/package builds, CI workflows, and project documentation.
## Local development and builds
Requirements: .NET SDK 10 and Node.js/npm. The SDK selection is defined by the root `global.json`; NuGet package versions are centralized in the root `Directory.Packages.props`.
- All .NET projects: `dotnet build MoShiro.slnx -c Release` from the repository root.
- Backend build: `cd backend && dotnet build MoShiro.slnx -c Release`
- Backend tests: `cd backend && dotnet test --solution MoShiro.slnx -c Release`
- Frontend dependencies/build: `npm ci --prefix web && npm run build --prefix web`
- Frontend style check: `npm run lint:css --prefix web`
- Local backend: from `backend/`, run `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`.
- Local web apps: from the repository root, run `npm ci --prefix web` then `npm run dev --prefix web`. The current dev config uses display port 5173 and admin port 5175; `/admin/` is proxied through the display server. Check the Vite configs when working on another branch because ports can differ.
- After changing API contracts, regenerate/check `backend/openapi/v1.json` and run `npm run gen:api --prefix web` so frontend API types stay aligned.
Follow the user's requested validation scope. Do not start a full tag/release build unless requested.
## CI and releases
CI triggers and image tags can change; inspect `.woodpecker/*.yml` and `docs/deployment.md` before changing or describing them. In particular, check the distinction between `[CI]` main builds, `[SKIP TESTS]`, and version-tag releases in the current branch.
A version tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline. Development image builds and releases must remain isolated in their respective Zot repository paths.
@@ -1,10 +1,15 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- Native SQLite source is pinned separately from the JIT/test binary package. -->
<MoShiroSqliteSourceVersion>3530400</MoShiroSqliteSourceVersion>
<MoShiroSqliteSourceYear>2026</MoShiroSqliteSourceYear>
<MoShiroSqliteSourceSha256>1e71ddf93849c6a6ecf58b827c0692073d2dd7ee40196158068f7b29f422e87d</MoShiroSqliteSourceSha256>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.12" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.12" />
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.53.3" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" /> <PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
+52
View File
@@ -0,0 +1,52 @@
<Solution>
<Folder Name="/backend/fleet/src/">
<Project Path="backend/fleet/src/Fleet.Core/Fleet.Core.csproj" />
<Project Path="backend/fleet/src/Fleet.Enrollment.Client/Fleet.Enrollment.Client.csproj" />
<Project Path="backend/fleet/src/Fleet.Hosting/Fleet.Hosting.csproj" />
<Project Path="backend/fleet/src/Fleet.Identity/Fleet.Identity.csproj" />
<Project Path="backend/fleet/src/Fleet.Link/Fleet.Link.csproj" />
<Project Path="backend/fleet/src/Fleet.Rpc/Fleet.Rpc.csproj" />
<Project Path="backend/fleet/src/Fleet.Server.Abstractions/Fleet.Server.Abstractions.csproj" />
<Project Path="backend/fleet/src/Fleet.Server.Enrollment/Fleet.Server.Enrollment.csproj" />
<Project Path="backend/fleet/src/Fleet.Server.Link/Fleet.Server.Link.csproj" />
</Folder>
<Folder Name="/backend/fleet/tests/">
<Project Path="backend/fleet/tests/Fleet.Tests/Fleet.Tests.csproj" />
</Folder>
<Folder Name="/backend/fleet/tools/">
<Project Path="backend/fleet/tools/fleet-enroll/fleet-enroll.csproj" />
</Folder>
<Folder Name="/backend/shared/">
<Project Path="backend/shared/MoShiro.Measurement/MoShiro.Measurement.csproj" />
<Project Path="backend/shared/MoShiro.Protocol/MoShiro.Protocol.csproj" />
</Folder>
<Folder Name="/backend/shared/tests/">
<Project Path="backend/shared/tests/MoShiro.Measurement.Tests/MoShiro.Measurement.Tests.csproj" />
<Project Path="backend/shared/tests/MoShiro.Protocol.Tests/MoShiro.Protocol.Tests.csproj" />
</Folder>
<Folder Name="/backend/agent/src/">
<Project Path="backend/agent/src/MoShiro.Agent/MoShiro.Agent.csproj" />
</Folder>
<Folder Name="/backend/probe/src/">
<Project Path="backend/probe/src/MoShiro.Probe/MoShiro.Probe.csproj" />
</Folder>
<Folder Name="/backend/probe/tests/">
<Project Path="backend/probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj" />
</Folder>
<Folder Name="/backend/src/">
<Project Path="backend/src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
<Project Path="backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj" />
<Project Path="backend/src/MoShiro.Backend.Application/MoShiro.Backend.Application.csproj" />
<Project Path="backend/src/MoShiro.Backend.Gateway/MoShiro.Backend.Gateway.csproj" />
<Project Path="backend/src/MoShiro.Backend.Storage.Sqlite/MoShiro.Backend.Storage.Sqlite.csproj" />
<Project Path="backend/src/MoShiro.Backend/MoShiro.Backend.csproj" />
</Folder>
<Folder Name="/backend/tests/">
<Project Path="backend/tests/MoShiro.Backend.Analysis.Tests/MoShiro.Backend.Analysis.Tests.csproj" />
<Project Path="backend/tests/MoShiro.Backend.Tests/MoShiro.Backend.Tests.csproj" />
<Project Path="backend/tests/MoShiro.TestSupport/MoShiro.TestSupport.csproj" />
</Folder>
<Folder Name="/ci/">
<Project Path="ci/MoShiro.Tar/MoShiro.Tar.csproj" />
</Folder>
</Solution>
+85 -64
View File
@@ -1,94 +1,115 @@
# Monitor # MoShiro
Monitor 是一个自托管的网络线路质量观测工具。它在 Probe 与多个 Agent 之间执行主动网络测量,记录逐包和逐事件结果;Backend 保存原始数据并生成可查询的线路指标,Web 界面用于查看、比较和管理节点。 MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 Probe(例如家宽出口)与多台 Agent(例如海外 VPS)之间做主动网络测量,只记录逐包、逐事件的原始数据;Backend 保存这些原始数据,推算延迟、丢包、抖动、吞吐等指标,并回答「如果在这条线路上部署某类服务,体验会怎样」;Web 界面用来查看、比较和管理。
它面向需要比较不同网络出口、评估线路稳定性,或观察特定网络服务体验的场景。Agent 与 Probe 专注于测量和上报;它们不提供远程终端、命令执行或文件管理能力。 ## 项目边界
## 能力概览 **它做什么**
- **主动测量**:组合 TCP、UDP 等测量模块,采集延迟、丢包、抖动、吞吐和连接事件。 - 主动测量:组合 TCP、UDP、TLS、ICMP 等采集积木,只记录时间、序号、大小这类原始事实,**不转发也不记录业务内容**。
- **线路分析**:从原始测量数据生成时间序列指标,并比较线路在不同服务模型下的体验。 - 线路分析:Backend 从原始数据生成时间序列指标,按服务模型(REALITY、Hysteria2、Minecraft 等)评分并对多条线路排序、对比;模型和指标可以事后重算。
- **节点管理**:通过 Fleet 节点框架处理身份、注册、连接、任务同步和数据上报。 - 管理:节点注册与审批、Probe 和 Agent 之间连哪些线路、任务与按需测试、节点版本与更新,都在管理端完成。
- **实时面板**:展示端查看线路状态和历史趋势;管理端负责节点、任务和系统配置。
- **自托管**:Backend 使用段文件和 SQLite 保存数据;提供 Linux、systemd 和 OpenWrt 相关部署文件。 **它不做什么**
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
- 暂不包含告警(见文末待办)。
## 信任关系
```text
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
Probe ── 用同一把密钥签名建立测量会话 ───────────────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ Backend 原样转发 ──▶ 节点宿主机的更新器用本机公钥验签
```
- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令。
- **更新不依赖面板的诚实**:清单必须由发布私钥签名;节点宿主机的更新器用本机公钥验签、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。
## 架构 ## 架构
```text ```text
Probe <──测量会话──> Agent ──长连接与数据上报──> Backend <──REST / SSE──> Web 展示端与管理端 Web(展示端 · 管理端)
├── 段文件:原始数据 ▲ REST + SSE
└── SQLite:索引、指标与配置 ┌──────────────────────────────────────────────────────────┐
│ Backend(面板) │
│ Api · Registry · Planner · Links │
│ Releases · Analysis · LiveHub │
│ Store(段文件 + SQLite) │
└──────────────────────────────────────────────────────────┘
▲ ▲
│ wss 长连接(节点发起) │ wss 长连接(节点发起)
│ 任务、数据、状态、更新通知 │ 身份、Agent 列表、状态、更新通知
┌────────────────────┐ ┌──────────────────────┐
│ Agent × 10+ │ ◀── 测量会话 ── │ Probe × N │
│ 有公网 IP,监听 │ │ 家宽等出口,NAT 后 │
└────────────────────┘ └──────────────────────┘
测量会话:TLS 控制通道 + UDP/TCP 数据通道,由 Probe 主动拨出
``` ```
## 快速开始 两种连接:**Probe 和 Agent 都用长连接连 Backend**(身份、任务、状态、更新通知),**测量只走 Probe → Agent**(Probe 主动拨出,Agent 不需要连 Probe,家宽也不需要入站端口)。哪些 Probe 连哪些 Agent 由 Backend 决定:默认自动全连,也可以在管理端手动选择。
### 环境要求 ```text
- .NET SDK 10;项目使用 `backend/global.json` 指定 SDK 版本和滚动策略。 采集(只记录) 传输(有界、至少一次) 分析(注册制、可重算) 展示
- Node.js 与 npm,用于构建 Web 前端。 Probe / Agent 积木 ──▶ 原始列块(10 秒一块)──▶ Backend 段文件 ──▶ 指标与服务模型 ──▶ REST / SSE ──▶ Web
```
### 构建 Backend 用段文件保存原始数据,用 SQLite 保存索引、指标与配置。**完整的架构、数据链路、安全边界和设计决策见 [`docs/architecture.md`](docs/architecture.md)。**
从仓库根目录构建后端: ## 协议
- **控制链路(Fleet)**:节点到 Backend 的 `wss` 长连接(`/api/agent/v1/rpc`),JSON-RPC 2.0 信封加二进制通道;挑战加签名认证,`hello` 协商版本和能力,心跳;**期望状态同步**(Backend 下发完整快照,节点调和并回报已应用的版本和被拒绝的条目);**可靠发件箱**(原始块至少一次投递,按 id 去重)。Fleet 与业务无关,放在 `backend/fleet/`。
- **业务方法**:`moshiro.*`(按需测试、Probe 断开、事件、主机汇总、更新通知等),状态文档有 `task_set`(Agent 的任务集)和 `probe_agents`(Probe 的 Agent 列表)。
- **测量数据面**:Probe 到 Agent 的 TLS 控制通道、UDP 和 TCP 数据通道(默认 `7443/tcp`、`7443/udp`、`7444/tcp`);会话用 `session_id` 和 MAC 认证、不绑定五元组,NAT 映射变化后自动跟随。
- **REST + SSE**:`/api/public`(展示,可匿名)、`/api/admin`(管理);OpenAPI 在 `backend/openapi/v1.json`,前端据此生成类型。
细节见 [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md)、[`docs/api.md`](docs/api.md)、[`docs/enrollment.md`](docs/enrollment.md)。
## 如何构建
环境:.NET SDK 10(`global.json` 指定版本)、Node.js 与 npm。在仓库根目录:
```sh ```sh
cd backend dotnet build MoShiro.slnx -c Release # Backend、Agent、Probe、Fleet、测试和 CI 工具
dotnet build Monitor.slnx -c Release npm ci --prefix web && npm run build --prefix web # 展示端和管理端,产物在 web/display/dist、web/admin/dist
``` ```
安装前端依赖并构建展示端和管理端(在仓库根目录运行): 本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。
测试和检查:
```sh ```sh
npm ci --prefix web dotnet test --solution MoShiro.slnx # 后端和节点的测试
npm run build --prefix web npm run lint:css --prefix web # 前端样式(颜色只能写在主题令牌里)
npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型
``` ```
前端产物位于 `web/display/dist/` 和 `web/admin/dist/`。NativeAOT 目标平台和 Windows 上使用 AotAnywhere 交叉构建的说明见 [`docs/deployment.md`](docs/deployment.md)。 Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/deployment.md`](docs/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
Backend 以 OCI 镜像发布,支持 Linux/amd64 与 Linux/arm64;Agent 和 Probe 发布 Linux glibc/musl x64、arm64,macOS x64/arm64,以及 Windows x64/arm64 二进制包。容器部署配置见 [`compose.yaml`](compose.yaml),发布说明见 [`docs/deployment.md`](docs/deployment.md)。 ## 文档
### 本地运行 | 文档 | 内容 |
1. **启动 Backend。** 在仓库根目录运行:
```sh
cd backend
dotnet run --project src/Monitor.Backend -- --urls http://localhost:5000
```
2. **启动前端。** 保持 Backend 运行,在另一个终端的仓库根目录运行:
```sh
npm ci --prefix web
npm run dev --prefix web
```
打开 <http://localhost:5173/> 使用展示端,或访问 <http://localhost:5174/admin/> 进入管理端。依赖安装和前端配置见 [`web/README.md`](web/README.md);Backend 的首次启动与配置见 [`backend/README.md`](backend/README.md)。
## 项目布局
| 路径 | 说明 |
| --- | --- | | --- | --- |
| [`backend/`](backend/README.md) | Backend 服务、Fleet 框架、共享测量代码与 .NET 测试 | | [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
| [`backend/agent/`](backend/agent/README.md) | Agent 节点:注册到 Backend,接受 Probe 测量并上报数据 | | [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
| [`backend/probe/`](backend/probe/README.md) | Probe 节点:发起线路测量并将采集数据交给 Agent | | [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
| `backend/fleet/` | 可复用的节点身份、注册和连接框架 | | [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
| `backend/shared/` | 测量引擎、协议和原始数据格式 | | [`docs/storage.md`](docs/storage.md)、[`docs/query.md`](docs/query.md) | 存储、展示数据查询 |
| `web/display/`、`web/admin/` | 展示端和管理端 | | [`docs/api.md`](docs/api.md)、[`docs/api-guidelines.md`](docs/api-guidelines.md) | API 规范与约束 |
| `web/shared/` | 前端 API、图表、组件与主题 | | [`docs/deployment.md`](docs/deployment.md) | 构建、发布、CI、部署与更新 |
| `packaging/`、`scripts/` | 部署、构建与维护脚本 | | [`docs/project-layout.md`](docs/project-layout.md)、[`docs/coding-style.md`](docs/coding-style.md) | 仓库结构与依赖规则、代码风格 |
| `docs/` | [架构](docs/architecture.md)、[测量模型](docs/measurement.md)、[API](docs/api.md)、[部署](docs/deployment.md)等文档 |
## 测试 代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
```sh ## 待办
cd backend
dotnet test --solution Monitor.slnx
```
```sh 见 [`TODO.md`](TODO.md)。
# 前端样式检查(仓库根目录)
npm run lint:css --prefix web
```
## 许可证 ## 许可证
+49
View File
@@ -0,0 +1,49 @@
# TODO
按主题记录还没做的事,做完就删掉对应的条目。已经实现的东西以 [`docs/`](docs/) 和代码为准。
## 测量
- 路由跳数、路径漂移和 trace
- 握手耗时:TCP 连接耗时,TLS / QUIC 握手耗时(QUIC 目前没有真实握手积木)
- 网页长截图
- pcap / eBPF 采集
## 任务与面板
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
- 原生 webhook 投递;告警(基于 webhook)
- Agent 和 Probe 的定时重启(可选,例如早上四点)
## 部署
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
## 访客页面
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
## 更新
- 更新遥测(现在面板只有每个节点的「当前更新状态」)
- **回滚路径的实机测试**:容器节点更新后观察 20 秒、失败回滚,只有单元和脚本测试,没有在真实节点上触发过。
- **更新的灰度和自动策略**:按标签分批发布;`POLICY=auto` 的长期验证。
- **纯二进制节点的更新**(`fleet-updater`、`fleet-release`、Windows 服务和 OpenWrt procd 适配器)。设计要点,实现时照这个来:
- 信任来自签名:只接受离线发布密钥签名的清单,发布源可以是面板、GitHub Releases 或任意 HTTP 服务;面板被攻破也推不出恶意二进制。
- 本地说了算:更新策略(`auto` / `notify` / `pinned:<version>`)和信任的公钥写在节点本地配置里,面板只能「建议」;支持多把公钥以便轮换。
- 清单带 `app`、`channel`、`version`、`min_from`、各 RID 制品的 `sha256` 和大小、`key_id`、签名(对规范化 JSON 签名);本地记录已装过的最高版本,拒绝降级和旧清单重放。
- 流程:下载 → 校验 → 解包到 `versions/<v>/` → 原子切换 `current`(Linux 符号链接,Windows 目录联结)→ 重启服务 → 等健康信标确认 → 失败切回旧版本并把该版本标记为坏版本。
- 权限分离:更新进程只能写安装目录和重启被管理的服务,读不到节点身份私钥。
- OpenWrt:`keep_versions = 1`,下载前检查闪存剩余空间。
- 容器节点已经实现的那一套(清单签名、`seq` 防重放、按 digest 拉取、回滚)见 [`docs/deployment.md`](docs/deployment.md)。
## 安全
- **Agent、Backend、Probe 的零信任设计**:目前 Backend 是三者之间的信任中心,需要梳理哪些信任可以去掉。
- **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
## 打包
- OpenWrt:procd 脚本、ipk/apk 包、sysupgrade 保留列表,在 aarch64 路由器上长时间运行验证。
- Windows 服务适配。
+1 -1
View File
@@ -13,7 +13,7 @@
<UseArtifactsOutput>true</UseArtifactsOutput> <UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath> <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
<Version>0.1.0</Version> <Version>0.1.1</Version>
<NuGetAudit>false</NuGetAudit> <NuGetAudit>false</NuGetAudit>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+16 -16
View File
@@ -17,33 +17,33 @@
<Project Path="fleet/tools/fleet-enroll/fleet-enroll.csproj" /> <Project Path="fleet/tools/fleet-enroll/fleet-enroll.csproj" />
</Folder> </Folder>
<Folder Name="/shared/"> <Folder Name="/shared/">
<Project Path="shared/Monitor.Measurement/Monitor.Measurement.csproj" /> <Project Path="shared/MoShiro.Measurement/MoShiro.Measurement.csproj" />
<Project Path="shared/Monitor.Protocol/Monitor.Protocol.csproj" /> <Project Path="shared/MoShiro.Protocol/MoShiro.Protocol.csproj" />
</Folder> </Folder>
<Folder Name="/shared/tests/"> <Folder Name="/shared/tests/">
<Project Path="shared/tests/Monitor.Measurement.Tests/Monitor.Measurement.Tests.csproj" /> <Project Path="shared/tests/MoShiro.Measurement.Tests/MoShiro.Measurement.Tests.csproj" />
<Project Path="shared/tests/Monitor.Protocol.Tests/Monitor.Protocol.Tests.csproj" /> <Project Path="shared/tests/MoShiro.Protocol.Tests/MoShiro.Protocol.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/agent/src/"> <Folder Name="/agent/src/">
<Project Path="agent/src/Monitor.Agent/Monitor.Agent.csproj" /> <Project Path="agent/src/MoShiro.Agent/MoShiro.Agent.csproj" />
</Folder> </Folder>
<Folder Name="/probe/src/"> <Folder Name="/probe/src/">
<Project Path="probe/src/Monitor.Probe/Monitor.Probe.csproj" /> <Project Path="probe/src/MoShiro.Probe/MoShiro.Probe.csproj" />
</Folder> </Folder>
<Folder Name="/probe/tests/"> <Folder Name="/probe/tests/">
<Project Path="probe/tests/Monitor.Probe.Tests/Monitor.Probe.Tests.csproj" /> <Project Path="probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/backend/src/"> <Folder Name="/backend/src/">
<Project Path="src/Monitor.Backend.Analysis/Monitor.Backend.Analysis.csproj" /> <Project Path="src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
<Project Path="src/Monitor.Backend.Api/Monitor.Backend.Api.csproj" /> <Project Path="src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj" />
<Project Path="src/Monitor.Backend.Application/Monitor.Backend.Application.csproj" /> <Project Path="src/MoShiro.Backend.Application/MoShiro.Backend.Application.csproj" />
<Project Path="src/Monitor.Backend.Gateway/Monitor.Backend.Gateway.csproj" /> <Project Path="src/MoShiro.Backend.Gateway/MoShiro.Backend.Gateway.csproj" />
<Project Path="src/Monitor.Backend.Storage.Sqlite/Monitor.Backend.Storage.Sqlite.csproj" /> <Project Path="src/MoShiro.Backend.Storage.Sqlite/MoShiro.Backend.Storage.Sqlite.csproj" />
<Project Path="src/Monitor.Backend/Monitor.Backend.csproj" /> <Project Path="src/MoShiro.Backend/MoShiro.Backend.csproj" />
</Folder> </Folder>
<Folder Name="/backend/tests/"> <Folder Name="/backend/tests/">
<Project Path="tests/Monitor.Backend.Analysis.Tests/Monitor.Backend.Analysis.Tests.csproj" /> <Project Path="tests/MoShiro.Backend.Analysis.Tests/MoShiro.Backend.Analysis.Tests.csproj" />
<Project Path="tests/Monitor.Backend.Tests/Monitor.Backend.Tests.csproj" /> <Project Path="tests/MoShiro.Backend.Tests/MoShiro.Backend.Tests.csproj" />
<Project Path="tests/Monitor.TestSupport/Monitor.TestSupport.csproj" /> <Project Path="tests/MoShiro.TestSupport/MoShiro.TestSupport.csproj" />
</Folder> </Folder>
</Solution> </Solution>
+13 -13
View File
@@ -1,24 +1,24 @@
# Backend # Backend
Backend 是 Monitor 的控制面和数据服务,负责 Agent 注册与连接、任务编排、原始测量数据持久化、指标分析,以及供前端使用的 REST API 和 SSE 实时数据流。 Backend 是 MoShiro 的控制面和数据服务,负责 Agent 注册与连接、任务编排、原始测量数据持久化、指标分析,以及供前端使用的 REST API 和 SSE 实时数据流。
## 组件 ## 组件
| 路径 | 职责 | | 路径 | 职责 |
| --- | --- | | --- | --- |
| `src/Monitor.Backend/` | 应用入口与依赖组合 | | `src/MoShiro.Backend/` | 应用入口与依赖组合 |
| `src/Monitor.Backend.Api/` | Public、Admin API 和 OpenAPI | | `src/MoShiro.Backend.Api/` | Public、Admin API 和 OpenAPI |
| `src/Monitor.Backend.Gateway/` | Fleet RPC、节点状态与测量数据接收 | | `src/MoShiro.Backend.Gateway/` | Fleet RPC、节点状态与测量数据接收 |
| `src/Monitor.Backend.Application/` | 业务用例、查询、任务和后台服务 | | `src/MoShiro.Backend.Application/` | 业务用例、查询、任务和后台服务 |
| `src/Monitor.Backend.Analysis/` | 指标计算、累加器和服务模型 | | `src/MoShiro.Backend.Analysis/` | 指标计算、累加器和服务模型 |
| `src/Monitor.Backend.Storage.Sqlite/` | 段文件与 SQLite 存储 | | `src/MoShiro.Backend.Storage.Sqlite/` | 段文件与 SQLite 存储 |
| `fleet/` | 通用节点身份、注册和连接框架 | | `fleet/` | 通用节点身份、注册和连接框架 |
| `shared/` | 测量引擎、协议和数据格式 | | `shared/` | 测量引擎、协议和数据格式 |
| `tests/` | 集成测试和测试支持代码 | | `tests/` | 集成测试和测试支持代码 |
## 环境要求 ## 环境要求
使用 .NET SDK 10。仓库根目录的 `backend/global.json` 指定所需 SDK 版本及滚动策略。 使用 .NET SDK 10。仓库根目录的 `global.json` 指定所需 SDK 版本及滚动策略。
## 构建与测试 ## 构建与测试
@@ -26,14 +26,14 @@ Backend 是 Monitor 的控制面和数据服务,负责 Agent 注册与连接
```sh ```sh
cd backend cd backend
dotnet build Monitor.slnx -c Release dotnet build MoShiro.slnx -c Release
dotnet test --solution Monitor.slnx dotnet test --solution MoShiro.slnx
``` ```
发布当前平台的 NativeAOT Backend: 发布当前平台的 NativeAOT Backend:
```sh ```sh
dotnet publish src/Monitor.Backend -c Release -r <RID> -p:PublishAot=true dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true
``` ```
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/deployment.md`](../docs/deployment.md)。 例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/deployment.md`](../docs/deployment.md)。
@@ -46,9 +46,9 @@ dotnet publish src/Monitor.Backend -c Release -r <RID> -p:PublishAot=true
```sh ```sh
cd backend cd backend
dotnet run --project src/Monitor.Backend -- --urls http://localhost:5000 dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
``` ```
首次启动时可用 `Monitor__InitialAdminUser` 和 `Monitor__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `Monitor` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。 首次启动时可用 `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)。 OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
+4
View File
@@ -8,6 +8,10 @@
<EventSourceSupport>false</EventSourceSupport> <EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport> <DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport> <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<ServerGarbageCollection>false</ServerGarbageCollection> <ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection> <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup> </PropertyGroup>
+13 -7
View File
@@ -1,4 +1,4 @@
# Monitor Agent # MoShiro Agent
Agent 是部署在 VPS 或其他可达网络节点上的采集进程。它向 Backend 注册并保持连接,接受 Probe 的测量会话,执行线路任务中的 Agent 侧工作,再将两端数据合并后上报。 Agent 是部署在 VPS 或其他可达网络节点上的采集进程。它向 Backend 注册并保持连接,接受 Probe 的测量会话,执行线路任务中的 Agent 侧工作,再将两端数据合并后上报。
@@ -14,18 +14,18 @@ Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据
## 构建 ## 构建
需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建: 需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建:
```sh ```sh
cd backend cd backend
dotnet build agent/src/Monitor.Agent -c Release dotnet build agent/src/MoShiro.Agent -c Release
dotnet build fleet/tools/fleet-enroll -c Release dotnet build fleet/tools/fleet-enroll -c Release
``` ```
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配: 发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```sh ```sh
dotnet publish agent/src/Monitor.Agent -c Release -r <RID> -p:PublishAot=true dotnet publish agent/src/MoShiro.Agent -c Release -r <RID> -p:PublishAot=true
dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
``` ```
@@ -36,7 +36,7 @@ dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
先在管理端创建加入令牌,然后在 Agent 主机上运行一次性注册工具: 先在管理端创建加入令牌,然后在 Agent 主机上运行一次性注册工具:
```sh ```sh
fleet-enroll --server https://<backend> --join <token> --name <agent-name> --identity-dir /etc/monitor fleet-enroll --server https://<backend> --join <token> --name <agent-name> --identity-dir /etc/moshiro
``` ```
注册工具会在身份目录写入节点身份和证书。准备本地 `agent.json`,其中 `fleet.server` 指向 Backend,`fleet.identity_dir` 指向上述目录;`listen`、`limits` 和 `reporting` 可按节点环境调整。配置文件不会由 Backend 修改。限制目标网段和测量预算时,应按实际网络与机器能力配置;`allowed_target_cidrs` 默认为空,不允许 Agent 执行目标探测任务。 注册工具会在身份目录写入节点身份和证书。准备本地 `agent.json`,其中 `fleet.server` 指向 Backend,`fleet.identity_dir` 指向上述目录;`listen`、`limits` 和 `reporting` 可按节点环境调整。配置文件不会由 Backend 修改。限制目标网段和测量预算时,应按实际网络与机器能力配置;`allowed_target_cidrs` 默认为空,不允许 Agent 执行目标探测任务。
@@ -44,10 +44,14 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
启动 Agent: 启动 Agent:
```sh ```sh
monitor-agent --config /etc/monitor/agent.json moshiro-agent --config /etc/moshiro/agent.json
``` ```
不传 `--config` 时默认读取 `/etc/monitor/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。 容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。
不传 `--config` 时默认读取 `/etc/moshiro/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。
IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为 `::`,覆盖整个双栈接口;已有配置如果仍写 `0.0.0.0`,可改为 `::` 后重启。Compose 与 systemd 容器部署使用宿主网络,诊断反映宿主 IPv6;自定义容器网络时,Agent 同时报告容器网络以及可读取到的宿主 `/proc` IPv6 状态。诊断不上传 IP 或接口名称。
## 设计说明 ## 设计说明
@@ -55,3 +59,5 @@ monitor-agent --config /etc/monitor/agent.json
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。 - 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。 - 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。
- 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。 - 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。
Docker 与 rootless/rootful Podman 共用 `packaging/compose/agent/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
@@ -4,17 +4,18 @@ using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using Fleet.Link; using Fleet.Link;
using Fleet.Rpc; using Fleet.Rpc;
using Monitor.Measurement; using MoShiro.Measurement;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Agent; namespace MoShiro.Agent;
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary> /// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
public sealed class AgentApp : IDisposable public sealed class AgentApp : IDisposable
{ {
public const string Version = "0.1.0"; public const string Version = MoShiro.Protocol.BuildInfo.Version;
private readonly AgentConfig _config; private readonly AgentConfig _config;
private readonly bool _ipv6Supported;
private readonly LoadedNodeIdentity _identity; private readonly LoadedNodeIdentity _identity;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly INodeLog _log; private readonly INodeLog _log;
@@ -25,6 +26,7 @@ public sealed class AgentApp : IDisposable
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission; private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20); private readonly Outbox _outbox = new(64 << 20);
private readonly UpdateBridge _update;
private readonly BackendUplink _uplink; private readonly BackendUplink _uplink;
private readonly UdpMux _udp; private readonly UdpMux _udp;
private readonly TcpAcceptor _tcp; private readonly TcpAcceptor _tcp;
@@ -34,8 +36,10 @@ public sealed class AgentApp : IDisposable
private readonly TestRunner _tests; private readonly TestRunner _tests;
private readonly FleetLink _link; private readonly FleetLink _link;
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, TimeProvider time, INodeLog log) /// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param>
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log)
{ {
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
_config = config; _config = config;
_identity = identity; _identity = identity;
_time = time; _time = time;
@@ -44,6 +48,12 @@ public sealed class AgentApp : IDisposable
_uplink = new BackendUplink(time); _uplink = new BackendUplink(time);
IPAddress bind = IPAddress.Parse(config.Listen.Bind); IPAddress bind = IPAddress.Parse(config.Listen.Bind);
if (bind.Equals(IPAddress.IPv6Any) && !System.Net.Sockets.Socket.OSSupportsIPv6)
{
bind = IPAddress.Any;
}
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
_admission = new AgentAdmission(_streams); _admission = new AgentAdmission(_streams);
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts); _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); _tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data);
@@ -182,39 +192,49 @@ public sealed class AgentApp : IDisposable
HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null; HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null;
bool Allow(string method) => allowed is null || allowed.Contains(method); bool Allow(string method) => allowed is null || allowed.Contains(method);
if (Allow(MonitorMethods.TestRun)) if (Allow(MoShiroMethods.TestRun))
{ {
router.On( router.On(
MonitorMethods.TestRun, MoShiroMethods.TestRun,
MonitorJsonContext.Default.TestRunParams, MoShiroJsonContext.Default.TestRunParams,
MonitorJsonContext.Default.TestRunResult, MoShiroJsonContext.Default.TestRunResult,
(_, run, _) => ValueTask.FromResult(_tests.Start(run))); (_, run, _) => ValueTask.FromResult(_tests.Start(run)));
} }
if (Allow(MonitorMethods.TestCancel)) if (Allow(MoShiroMethods.TestCancel))
{ {
router.On( router.On(
MonitorMethods.TestCancel, MoShiroMethods.TestCancel,
MonitorJsonContext.Default.TestCancelParams, MoShiroJsonContext.Default.TestCancelParams,
MonitorJsonContext.Default.TestRunResult, MoShiroJsonContext.Default.TestRunResult,
(_, cancel, _) => ValueTask.FromResult(Result<TestRunResult>.Ok(new TestRunResult { Accepted = _tests.Cancel(cancel.TestId) }))); (_, cancel, _) => ValueTask.FromResult(Result<TestRunResult>.Ok(new TestRunResult { Accepted = _tests.Cancel(cancel.TestId) })));
} }
if (Allow(MonitorMethods.ProbeDisconnect)) if (Allow(MoShiroMethods.UpdateOffer))
{ {
router.On( router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
MonitorMethods.ProbeDisconnect, {
MonitorJsonContext.Default.ProbeDisconnectParams, _update.WriteOffer(offer);
MonitorJsonContext.Default.TestRunResult, return ValueTask.CompletedTask;
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) })); });
} }
if (Allow(MonitorMethods.ProbeAgentsChanged)) if (Allow(MoShiroMethods.UpdateApply))
{ {
router.OnNotify( router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
MonitorMethods.ProbeAgentsChanged, {
MonitorJsonContext.Default.ProbeAgentsResult, _update.RequestApply();
(_, list, _) => _hub.RelayAgentsAsync(list)); return ValueTask.CompletedTask;
});
}
if (Allow(MoShiroMethods.ProbeDisconnect))
{
router.On(
MoShiroMethods.ProbeDisconnect,
MoShiroJsonContext.Default.ProbeDisconnectParams,
MoShiroJsonContext.Default.TestRunResult,
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) }));
} }
var options = new FleetLinkOptions var options = new FleetLinkOptions
@@ -223,11 +243,11 @@ public sealed class AgentApp : IDisposable
Identity = _identity.Identity, Identity = _identity.Identity,
NodeId = _identity.NodeId, NodeId = _identity.NodeId,
NodeVersion = Version, NodeVersion = Version,
Capabilities = [MonitorCapabilities.Base, MonitorCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)], Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
HelloMeta = HelloMeta, HelloMeta = HelloMeta,
HeartbeatExtra = HeartbeatExtra, HeartbeatExtra = HeartbeatExtra,
}; };
var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MonitorJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log); var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log);
// 链路重新连上之后补报:在线的 Probe 都告诉 Backend(Backend 重启或链路抖动期间错过了 probe_connected) // 链路重新连上之后补报:在线的 Probe 都告诉 Backend(Backend 重启或链路抖动期间错过了 probe_connected)
link.OnConnected = async (_, _) => link.OnConnected = async (_, _) =>
@@ -244,6 +264,9 @@ public sealed class AgentApp : IDisposable
{ {
var meta = new AgentHelloMeta var meta = new AgentHelloMeta
{ {
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Ipv6Supported = _ipv6Supported,
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
TlsSpki = _identity.File.TlsSpki, TlsSpki = _identity.File.TlsSpki,
ProbeTcpPort = ControlPort, ProbeTcpPort = ControlPort,
ProbeUdpPorts = [.. UdpPorts], ProbeUdpPorts = [.. UdpPorts],
@@ -255,7 +278,7 @@ public sealed class AgentApp : IDisposable
MaxConcurrentTests = _config.Limits.MaxConcurrentTests, MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
}, },
}; };
return JsonSerializer.SerializeToElement(meta, MonitorJsonContext.Default.AgentHelloMeta); return JsonSerializer.SerializeToElement(meta, MoShiroJsonContext.Default.AgentHelloMeta);
} }
private long _lastIn; private long _lastIn;
@@ -270,6 +293,7 @@ public sealed class AgentApp : IDisposable
long packetsOut = _counters.PacketsOut; long packetsOut = _counters.PacketsOut;
var extra = new AgentHeartbeatExtra var extra = new AgentHeartbeatExtra
{ {
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Probes = [.. _hub.ConnectedProbes()], Probes = [.. _hub.ConnectedProbes()],
Sessions = _sessions.All.Count, Sessions = _sessions.All.Count,
PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds, PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds,
@@ -277,10 +301,11 @@ public sealed class AgentApp : IDisposable
DroppedInvalid = _counters.DroppedInvalid, DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited, DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes, QueueBytes = _outbox.PendingBytes,
Update = _update.ReadStatus(),
}; };
_lastIn = packetsIn; _lastIn = packetsIn;
_lastOut = packetsOut; _lastOut = packetsOut;
_lastAtMs = nowMs; _lastAtMs = nowMs;
return JsonSerializer.SerializeToElement(extra, MonitorJsonContext.Default.AgentHeartbeatExtra); return JsonSerializer.SerializeToElement(extra, MoShiroJsonContext.Default.AgentHeartbeatExtra);
} }
} }
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Monitor.Agent; namespace MoShiro.Agent;
/// <summary> /// <summary>
/// <c>agent.json</c>:节点本地配置,面板无法修改。其中 <c>limits</c> 是本地硬上限:期望任务集超出这些范围时, /// <c>agent.json</c>:节点本地配置,面板无法修改。其中 <c>limits</c> 是本地硬上限:期望任务集超出这些范围时,
@@ -40,13 +40,13 @@ public sealed class FleetSection
public string Server { get; set; } = ""; public string Server { get; set; } = "";
[JsonPropertyName("identity_dir")] [JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/monitor"; public string IdentityDir { get; set; } = "/etc/moshiro";
} }
public sealed class ListenSection public sealed class ListenSection
{ {
[JsonPropertyName("bind")] [JsonPropertyName("bind")]
public string Bind { get; set; } = "0.0.0.0"; public string Bind { get; set; } = "::";
/// <summary>Probe 控制通道(TLS,点对点会话)。</summary> /// <summary>Probe 控制通道(TLS,点对点会话)。</summary>
[JsonPropertyName("control_port")] [JsonPropertyName("control_port")]
@@ -1,9 +1,9 @@
using Fleet.Core; using Fleet.Core;
using Fleet.Link; using Fleet.Link;
using Monitor.Measurement; using MoShiro.Measurement;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Agent; namespace MoShiro.Agent;
/// <summary> /// <summary>
/// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。 /// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
@@ -44,6 +44,7 @@ public sealed class ChunkAssembler
_ => null, _ => null,
}; };
header.ProbeId = new ProbeId(probe.ProbeId); header.ProbeId = new ProbeId(probe.ProbeId);
header.IpVersion = probe.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind); KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind);
if (stream is null || spec is null || spec.Id != header.Kind) if (stream is null || spec is null || spec.Id != header.Kind)
{ {
@@ -70,6 +71,6 @@ public sealed class ChunkAssembler
/// <summary>Agent 自己采集的块(目标任务)直接发出。</summary> /// <summary>Agent 自己采集的块(目标任务)直接发出。</summary>
public void Emit(RawChunk chunk) public void Emit(RawChunk chunk)
{ {
_outbox.Enqueue(MonitorChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk)); _outbox.Enqueue(MoShiroChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk));
} }
} }
@@ -4,10 +4,10 @@ using System.Net.Sockets;
using Fleet.Core; using Fleet.Core;
using Fleet.Link; using Fleet.Link;
using Fleet.Rpc; using Fleet.Rpc;
using Monitor.Measurement; using MoShiro.Measurement;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Agent; namespace MoShiro.Agent;
/// <summary>进程内计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。数据面的包计数由共享的 <see cref="DataPlaneCounters"/> 提供。</summary> /// <summary>进程内计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。数据面的包计数由共享的 <see cref="DataPlaneCounters"/> 提供。</summary>
public sealed class AgentCounters public sealed class AgentCounters
@@ -56,6 +56,10 @@ public sealed class ProbeSession : DataSession
public string ProbeId => Info.PeerId; public string ProbeId => Info.PeerId;
public string IpVersion => Info.Remote is IPEndPoint endpoint && endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6 && !endpoint.Address.IsIPv4MappedToIPv6 ? IpVersions.V6 : IpVersions.V4;
public string PathKey => ProbeId + "~" + IpVersion;
/// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary> /// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary>
public string? Version => Info.Version; public string? Version => Info.Version;
@@ -72,7 +76,7 @@ public sealed class ProbeSession : DataSession
get get
{ {
SocketAddress? source = Volatile.Read(ref _udpSource); SocketAddress? source = Volatile.Read(ref _udpSource);
return source is null ? null : (IPEndPoint)new IPEndPoint(IPAddress.Any, 0).Create(source); return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source);
} }
} }
@@ -125,7 +129,7 @@ public sealed class ProbeSession : DataSession
public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _); public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _);
} }
/// <summary>当前连着的 Probe 会话:每个 Probe 一个;同一个 Probe 重连时新会话取代它自己的旧会话。</summary> /// <summary>每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
public sealed class SessionRegistry : IDataSessions public sealed class SessionRegistry : IDataSessions
{ {
private readonly object _gate = new(); private readonly object _gate = new();
@@ -143,11 +147,11 @@ public sealed class SessionRegistry : IDataSessions
} }
} }
public ProbeSession? Get(string probeId) public ProbeSession? Get(string probeId, string ipVersion = IpVersions.V4)
{ {
lock (_gate) lock (_gate)
{ {
return _byProbe.GetValueOrDefault(probeId); return _byProbe.GetValueOrDefault(probeId + "~" + ipVersion);
} }
} }
@@ -156,13 +160,13 @@ public sealed class SessionRegistry : IDataSessions
{ {
lock (_gate) lock (_gate)
{ {
_byProbe.TryGetValue(session.ProbeId, out ProbeSession? old); _byProbe.TryGetValue(session.PathKey, out ProbeSession? old);
if (old is not null) if (old is not null)
{ {
_bySession.Remove(old.SessionId); _bySession.Remove(old.SessionId);
} }
_byProbe[session.ProbeId] = session; _byProbe[session.PathKey] = session;
_bySession[session.SessionId] = session; _bySession[session.SessionId] = session;
return old; return old;
} }
@@ -172,9 +176,9 @@ public sealed class SessionRegistry : IDataSessions
{ {
lock (_gate) lock (_gate)
{ {
if (_byProbe.TryGetValue(session.ProbeId, out ProbeSession? current) && ReferenceEquals(current, session)) if (_byProbe.TryGetValue(session.PathKey, out ProbeSession? current) && ReferenceEquals(current, session))
{ {
_byProbe.Remove(session.ProbeId); _byProbe.Remove(session.PathKey);
} }
if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session)) if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session))
@@ -1,28 +1,35 @@
using System.Globalization; using System.Globalization;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Agent; namespace MoShiro.Agent;
/// <summary> /// <summary>
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 /// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。 /// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c>;读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。 /// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
/// 读不到的项是 null(例如 Windows 上的 CPU)。不执行任何命令,不写文件。
/// </summary> /// </summary>
public sealed class HostSampler public sealed class HostSampler
{ {
private static readonly string[] s_virtualPrefixes = ["lo", "veth", "docker", "br-", "virbr", "cni", "flannel", "cali", "tun", "tap"]; private static readonly string[] s_virtualPrefixes = ["lo", "veth", "docker", "podman", "br-", "virbr", "cni", "flannel", "cali", "tun", "tap"];
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly Func<string, string?> _read; private readonly Func<string, string?> _read;
private readonly Func<(long Total, long Used)?> _disk; private readonly Func<(long Total, long Used)?> _disk;
private readonly string _procRoot;
private readonly string _sysRoot;
private (long Total, long Idle)? _previousCpu; private (long Total, long Idle)? _previousCpu;
private (long At, long Rx, long Tx)? _previousNet; private (long At, long Rx, long Tx)? _previousNet;
public HostSampler(TimeProvider time, Func<string, string?>? read = null, Func<(long Total, long Used)?>? disk = null) public HostSampler(TimeProvider time, Func<string, string?>? read = null, Func<(long Total, long Used)?>? disk = null,
string? procRoot = null, string? sysRoot = null, string? diskRoot = null)
{ {
_time = time; _time = time;
_read = read ?? ReadProc; _read = read ?? ReadProc;
_disk = disk ?? ReadRootDisk; _procRoot = Root(procRoot, "HOST_PROC", "/proc");
_sysRoot = Root(sysRoot, "HOST_SYS", "/sys");
string root = Root(diskRoot, "HOST_ROOT", OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/");
_disk = disk ?? (() => ReadRootDisk(root));
} }
public HostSnapshot Sample() public HostSnapshot Sample()
@@ -30,7 +37,7 @@ public sealed class HostSampler
long now = _time.GetTimestamp(); long now = _time.GetTimestamp();
var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() }; var snapshot = new HostSnapshot { AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
if (_read("/proc/stat") is { } stat && ParseCpu(stat) is { } cpu) if (_read(Path.Combine(_procRoot, "stat")) is { } stat && ParseCpu(stat) is { } cpu)
{ {
if (_previousCpu is { } before && cpu.Total > before.Total) if (_previousCpu is { } before && cpu.Total > before.Total)
{ {
@@ -41,7 +48,7 @@ public sealed class HostSampler
_previousCpu = cpu; _previousCpu = cpu;
} }
if (_read("/proc/meminfo") is { } meminfo && ParseMemory(meminfo) is { } memory) if (_read(Path.Combine(_procRoot, "meminfo")) is { } meminfo && ParseMemory(meminfo) is { } memory)
{ {
snapshot.MemTotal = memory.Total; snapshot.MemTotal = memory.Total;
snapshot.MemUsed = memory.Used; snapshot.MemUsed = memory.Used;
@@ -53,10 +60,11 @@ public sealed class HostSampler
snapshot.DiskUsed = disk.Used; snapshot.DiskUsed = disk.Used;
} }
if (_read("/proc/net/dev") is { } dev && ParseNetwork(dev) is { } net) if (_read(Path.Combine(_procRoot, "net/dev")) is { } dev && ParseNetwork(dev) is { } net)
{ {
snapshot.NetRxTotal = net.Rx; snapshot.NetRxTotal = net.Rx;
snapshot.NetTxTotal = net.Tx; snapshot.NetTxTotal = net.Tx;
snapshot.NetLinkMbps = ReadLinkSpeed(dev);
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx) if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{ {
double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds); double seconds = Math.Max(0.001, _time.GetElapsedTime(before.At, now).TotalSeconds);
@@ -67,7 +75,11 @@ public sealed class HostSampler
_previousNet = (now, net.Rx, net.Tx); _previousNet = (now, net.Rx, net.Tx);
} }
snapshot.UptimeSeconds = _read("/proc/uptime") is { } uptime && ParseUptime(uptime) is { } up ? up : Environment.TickCount64 / 1000; snapshot.UptimeSeconds = _read(Path.Combine(_procRoot, "uptime")) is { } uptime && ParseUptime(uptime) is { } up ? up : null;
if (!OperatingSystem.IsLinux() && snapshot.UptimeSeconds is null)
{
snapshot.UptimeSeconds = Environment.TickCount64 / 1000;
}
return snapshot; return snapshot;
} }
@@ -165,11 +177,54 @@ public sealed class HostSampler
} }
} }
private static (long Total, long Used)? ReadRootDisk() private static string Root(string? value, string variable, string fallback)
{
value ??= Environment.GetEnvironmentVariable(variable);
return string.IsNullOrWhiteSpace(value) ? fallback : value;
}
private long? ReadLinkSpeed(string dev)
{
long total = 0;
bool any = false;
foreach (string line in dev.Split('\n'))
{
int colon = line.IndexOf(':', StringComparison.Ordinal);
if (colon < 0)
{
continue;
}
string name = line[..colon].Trim();
if (s_virtualPrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal)))
{
continue;
}
string? speed = _read(Path.Combine(_sysRoot, "class/net", name, "speed"));
if (long.TryParse(speed?.Trim(), CultureInfo.InvariantCulture, out long mbps) && mbps > 0)
{
total += mbps;
any = true;
}
else
{
return null;
}
}
return any ? total : null;
}
private static (long Total, long Used)? ReadRootDisk(string root)
{ {
try try
{ {
string root = OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/"; if (!Directory.Exists(root))
{
return null;
}
var drive = new DriveInfo(root); var drive = new DriveInfo(root);
return drive.IsReady ? (drive.TotalSize, drive.TotalSize - drive.AvailableFreeSpace) : null; return drive.IsReady ? (drive.TotalSize, drive.TotalSize - drive.AvailableFreeSpace) : null;
} }
@@ -0,0 +1,26 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>moshiro-agent</AssemblyName>
<RootNamespace>MoShiro.Agent</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<StripSymbols>true</StripSymbols>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MoShiro.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
</Project>
@@ -2,10 +2,10 @@ using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Fleet.Link; using Fleet.Link;
using Fleet.Rpc; using Fleet.Rpc;
using Monitor.Measurement; using MoShiro.Measurement;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Agent; namespace MoShiro.Agent;
/// <summary>Agent → Backend 的上行:事件和测试状态是尽力而为的通知(链路断开时丢弃;列块走发件箱,不会丢)。</summary> /// <summary>Agent → Backend 的上行:事件和测试状态是尽力而为的通知(链路断开时丢弃;列块走发件箱,不会丢)。</summary>
public sealed class BackendUplink public sealed class BackendUplink
@@ -31,7 +31,7 @@ public sealed class BackendUplink
} }
var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Probe = probe }; var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Probe = probe };
await SafeAsync(() => session.NotifyAsync(MonitorMethods.Event, parameters, MonitorJsonContext.Default.AgentEventParams, CancellationToken.None)); await SafeAsync(() => session.NotifyAsync(MoShiroMethods.Event, parameters, MoShiroJsonContext.Default.AgentEventParams, CancellationToken.None));
} }
public async ValueTask SendHostAsync(HostSnapshot snapshot) public async ValueTask SendHostAsync(HostSnapshot snapshot)
@@ -42,7 +42,7 @@ public sealed class BackendUplink
return; return;
} }
await SafeAsync(() => session.NotifyAsync(MonitorMethods.Host, snapshot, MonitorJsonContext.Default.HostSnapshot, CancellationToken.None)); await SafeAsync(() => session.NotifyAsync(MoShiroMethods.Host, snapshot, MoShiroJsonContext.Default.HostSnapshot, CancellationToken.None));
} }
public async ValueTask SendTestStatusAsync(TestId test, string status, int? phase = null, string? errorCode = null) public async ValueTask SendTestStatusAsync(TestId test, string status, int? phase = null, string? errorCode = null)
@@ -54,19 +54,7 @@ public sealed class BackendUplink
} }
var parameters = new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode }; var parameters = new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode };
await SafeAsync(() => session.NotifyAsync(MonitorMethods.TestStatus, parameters, MonitorJsonContext.Default.TestStatusParams, CancellationToken.None)); await SafeAsync(() => session.NotifyAsync(MoShiroMethods.TestStatus, parameters, MoShiroJsonContext.Default.TestStatusParams, CancellationToken.None));
}
/// <summary>代 Probe 向 Backend 取 Agent 列表(Probe 不认识 Backend)。链路断开时返回 fleet_node_offline。</summary>
public async ValueTask<Result<ProbeAgentsResult>> RequestProbeAgentsAsync(ProbeAgentsParams request, CancellationToken ct)
{
RpcSession<LinkContext>? session = _link?.CurrentSession;
if (session is null)
{
return Result<ProbeAgentsResult>.Fail(FleetErrors.NodeOffline, "the agent has no link to the backend");
}
return await session.RequestAsync(MonitorMethods.ProbeAgents, request, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
} }
private static async ValueTask SafeAsync(Func<ValueTask> send) private static async ValueTask SafeAsync(Func<ValueTask> send)
@@ -104,7 +92,7 @@ public sealed class PeerHub
public PeerHub(SessionRegistry sessions, MonoClock clock, ChunkAssembler chunks, TaskManager tasks, BackendUplink uplink, TimeProvider time, INodeLog log) public PeerHub(SessionRegistry sessions, MonoClock clock, ChunkAssembler chunks, TaskManager tasks, BackendUplink uplink, TimeProvider time, INodeLog log)
{ {
_router = BuildRouter(uplink); _router = BuildRouter();
_sessions = sessions; _sessions = sessions;
_clock = clock; _clock = clock;
_chunks = chunks; _chunks = chunks;
@@ -127,7 +115,7 @@ public sealed class PeerHub
var order = new Queue<Guid>(); var order = new Queue<Guid>();
rpc.OnBinary = async (frame, token) => rpc.OnBinary = async (frame, token) =>
{ {
if (!OutboxFrame.TryParse(frame, out byte channel, out Guid id, out ReadOnlyMemory<byte> payload) || channel != MonitorChannels.ColumnBlock) if (!OutboxFrame.TryParse(frame, out byte channel, out Guid id, out ReadOnlyMemory<byte> payload) || channel != MoShiroChannels.ColumnBlock)
{ {
return; return;
} }
@@ -188,7 +176,7 @@ public sealed class PeerHub
await _tasks.CloseSessionAsync(probe); await _tasks.CloseSessionAsync(probe);
_log.Info("probe disconnected", ("probe", info.PeerId)); _log.Info("probe disconnected", ("probe", info.PeerId));
OnProbeLost?.Invoke(probe); OnProbeLost?.Invoke(probe);
await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId); await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId, Describe(probe));
} }
} }
@@ -220,7 +208,7 @@ public sealed class PeerHub
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary> /// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)]; public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs }; private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)]; public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
@@ -230,13 +218,17 @@ public sealed class PeerHub
/// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary> /// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
public async ValueTask<bool> DisconnectAsync(string probeId) public async ValueTask<bool> DisconnectAsync(string probeId)
{ {
if (_sessions.Get(probeId)?.Rpc is not { } rpc) bool disconnected = false;
foreach (ProbeSession probe in _sessions.All)
{ {
return false; if (probe.ProbeId == probeId && probe.Rpc is { } rpc)
{
await rpc.CloseAsync("disconnected", CancellationToken.None);
disconnected = true;
}
} }
await rpc.CloseAsync("disconnected", CancellationToken.None); return disconnected;
return true;
} }
/// <summary>名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。</summary> /// <summary>名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。</summary>
@@ -252,27 +244,6 @@ public sealed class PeerHub
} }
} }
/// <summary>Backend 的 Agent 列表变化时转交给所有 Probe(Probe 据此调整它连接的 Agent)。</summary>
public async ValueTask RelayAgentsAsync(ProbeAgentsResult list)
{
foreach (ProbeSession probe in _sessions.All)
{
if (probe.Rpc is not { } rpc)
{
continue;
}
try
{
await rpc.NotifyAsync(MonitorMethods.ProbeAgentsChanged, list, MonitorJsonContext.Default.ProbeAgentsResult, CancellationToken.None);
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException)
{
// 会话刚好结束
}
}
}
private async ValueTask PushAsync(ProbeSession probe, LineTaskSet set, CancellationToken ct) private async ValueTask PushAsync(ProbeSession probe, LineTaskSet set, CancellationToken ct)
{ {
if (probe.Rpc is null) if (probe.Rpc is null)
@@ -284,7 +255,7 @@ public sealed class PeerHub
{ {
Type = LineTaskSet.StateType, Type = LineTaskSet.StateType,
Version = set.Version, Version = set.Version,
Doc = JsonSerializer.SerializeToElement(set, MonitorJsonContext.Default.LineTaskSet), Doc = JsonSerializer.SerializeToElement(set, MoShiroJsonContext.Default.LineTaskSet),
}; };
try try
{ {
@@ -300,16 +271,11 @@ public sealed class PeerHub
} }
} }
private static RpcRouter<PeerContext> BuildRouter(BackendUplink uplink) => private static RpcRouter<PeerContext> BuildRouter() =>
new RpcRouter<PeerContext>() new RpcRouter<PeerContext>()
.On( .On(
MonitorMethods.ProbeAgents, MoShiroMethods.Clock,
MonitorJsonContext.Default.ProbeAgentsParams, MoShiroJsonContext.Default.ClockParams,
MonitorJsonContext.Default.ProbeAgentsResult, MoShiroJsonContext.Default.ClockResult,
(_, request, ct) => uplink.RequestProbeAgentsAsync(request, ct))
.On(
MonitorMethods.Clock,
MonitorJsonContext.Default.ClockParams,
MonitorJsonContext.Default.ClockResult,
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() }))); (context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() })));
} }
@@ -1,6 +1,6 @@
using Fleet.Core; using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using Monitor.Agent; using MoShiro.Agent;
TimeProvider time = TimeProvider.System; TimeProvider time = TimeProvider.System;
INodeLog log = new ConsoleNodeLog(time); INodeLog log = new ConsoleNodeLog(time);
@@ -11,7 +11,7 @@ if (PlatformCheck.VerifyCrypto() is { } problem)
return 1; return 1;
} }
string configPath = "/etc/monitor/agent.json"; string configPath = "/etc/moshiro/agent.json";
for (int i = 0; i < args.Length - 1; i++) for (int i = 0; i < args.Length - 1; i++)
{ {
if (args[i] == "--config") if (args[i] == "--config")
@@ -35,7 +35,7 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
using (identity) using (identity)
{ {
using var app = new AgentApp(config, identity, time, log); using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log);
await ServiceShell.RunAsync(app.RunAsync, log); await ServiceShell.RunAsync(app.RunAsync, log);
} }
@@ -1,10 +1,10 @@
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Monitor.Measurement; using MoShiro.Measurement;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Agent; namespace MoShiro.Agent;
/// <summary> /// <summary>
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect), /// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect),
@@ -80,7 +80,7 @@ public sealed class TargetRunners
Log = _log, Log = _log,
Session = () => null, Session = () => null,
PeerAddress = address, PeerAddress = address,
Fragments = new LocalSink(this, spec), Fragments = new LocalSink(this, spec, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
}; };
IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null))); IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null)));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -132,11 +132,13 @@ public sealed class TargetRunners
{ {
private readonly TargetRunners _owner; private readonly TargetRunners _owner;
private readonly TaskSpec _spec; private readonly TaskSpec _spec;
private readonly string _ipVersion;
public LocalSink(TargetRunners owner, TaskSpec spec) public LocalSink(TargetRunners owner, TaskSpec spec, string ipVersion)
{ {
_owner = owner; _owner = owner;
_spec = spec; _spec = spec;
_ipVersion = ipVersion;
} }
public void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port) public void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port)
@@ -149,6 +151,7 @@ public sealed class TargetRunners
TaskId = scope.Task, TaskId = scope.Task,
AgentId = _owner._agent, AgentId = _owner._agent,
Target = _spec.Target, Target = _spec.Target,
IpVersion = _ipVersion,
Params = parameters is null ? [] : new Dictionary<string, JsonElement>(parameters), Params = parameters is null ? [] : new Dictionary<string, JsonElement>(parameters),
Port = port, Port = port,
StartUtcMs = startUtcMs, StartUtcMs = startUtcMs,
@@ -1,10 +1,10 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Fleet.Rpc; using Fleet.Rpc;
using Monitor.Measurement; using MoShiro.Measurement;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Agent; namespace MoShiro.Agent;
/// <summary> /// <summary>
/// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。 /// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。
@@ -1,15 +1,15 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Fleet.Core; using Fleet.Core;
using Fleet.Rpc; using Fleet.Rpc;
using Monitor.Measurement; using MoShiro.Measurement;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Agent; namespace MoShiro.Agent;
/// <summary> /// <summary>
/// 按需测试的编排(<c>monitor.test.run</c>):Agent 是时间的主人。每个阶段: /// 按需测试的编排(<c>moshiro.test.run</c>):Agent 是时间的主人。每个阶段:
/// 登记数据面的流 → <c>monitor.test.phase</c>(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包) /// 登记数据面的流 → <c>moshiro.test.phase</c>(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包)
/// → <c>monitor.test.phase_end</c>(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。 /// → <c>moshiro.test.phase_end</c>(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
/// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。 /// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。
/// </summary> /// </summary>
public sealed class TestRunner public sealed class TestRunner
@@ -43,17 +43,22 @@ public sealed class TestRunner
public int Running => _running.Count; public int Running => _running.Count;
/// <summary>校验并接受一个测试(立即返回),实际运行在后台进行,状态通过 <c>monitor.test.status</c> 回报。</summary> /// <summary>校验并接受一个测试(立即返回),实际运行在后台进行,状态通过 <c>moshiro.test.status</c> 回报。</summary>
public Result<TestRunResult> Start(TestRunParams run) public Result<TestRunResult> Start(TestRunParams run)
{ {
if (_sessions.Get(run.ProbeId.Value) is not { } probe) if (!IpVersions.IsValid(run.IpVersion))
{ {
return Result<TestRunResult>.Fail(MonitorErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent"); return Result<TestRunResult>.Fail(MoShiroErrors.ValidationFailed, "ip_version must be ipv4 or ipv6");
}
if (_sessions.Get(run.ProbeId.Value, run.IpVersion) is not { } probe)
{
return Result<TestRunResult>.Fail(MoShiroErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent");
} }
if (run.Phases.Count == 0) if (run.Phases.Count == 0)
{ {
return Result<TestRunResult>.Fail(MonitorErrors.ValidationFailed, "a test needs at least one phase"); return Result<TestRunResult>.Fail(MoShiroErrors.ValidationFailed, "a test needs at least one phase");
} }
var resolved = new List<(TestPhase Phase, TaskDecision Decision)>(); var resolved = new List<(TestPhase Phase, TaskDecision Decision)>();
@@ -64,7 +69,7 @@ public sealed class TestRunner
if (!decision.Accepted) if (!decision.Accepted)
{ {
return Result<TestRunResult>.Fail( return Result<TestRunResult>.Fail(
decision.Reason == RejectReasons.Unsupported ? FleetErrors.Unsupported : MonitorErrors.BudgetExceeded, decision.Reason == RejectReasons.Unsupported ? FleetErrors.Unsupported : MoShiroErrors.BudgetExceeded,
$"phase {phase.Kind} refused: {decision.Reason}"); $"phase {phase.Kind} refused: {decision.Reason}");
} }
@@ -84,7 +89,7 @@ public sealed class TestRunner
if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running)) if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running))
{ {
running.Dispose(); running.Dispose();
return Result<TestRunResult>.Fail(MonitorErrors.TestBusy, "this agent is already running a test"); return Result<TestRunResult>.Fail(MoShiroErrors.TestBusy, "this agent is already running a test");
} }
_ = Task.Run(() => RunAsync(run.TestId, resolved, running), CancellationToken.None); _ = Task.Run(() => RunAsync(run.TestId, resolved, running), CancellationToken.None);
@@ -154,7 +159,7 @@ public sealed class TestRunner
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
finalStatus = running.Lost ? TestStatuses.Failed : TestStatuses.Aborted; finalStatus = running.Lost ? TestStatuses.Failed : TestStatuses.Aborted;
errorCode = running.Lost ? MonitorErrors.ProbeOffline.Value : null; errorCode = running.Lost ? MoShiroErrors.ProbeOffline.Value : null;
await TryAbortProbeAsync(running.Probe, test); await TryAbortProbeAsync(running.Probe, test);
} }
catch (TestFailure failure) catch (TestFailure failure)
@@ -183,7 +188,7 @@ public sealed class TestRunner
_streams.Add(stream); _streams.Add(stream);
try try
{ {
RpcSession<PeerContext> rpc = probe.Rpc ?? throw new TestFailure(MonitorErrors.ProbeOffline.Value); RpcSession<PeerContext> rpc = probe.Rpc ?? throw new TestFailure(MoShiroErrors.ProbeOffline.Value);
var start = new TestPhaseStartParams var start = new TestPhaseStartParams
{ {
TestId = test, TestId = test,
@@ -194,7 +199,7 @@ public sealed class TestRunner
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
Port = stream.Port, Port = stream.Port,
}; };
Result<TestPhaseStartResult> accepted = await rpc.RequestAsync(MonitorMethods.TestPhase, start, MonitorJsonContext.Default.TestPhaseStartParams, MonitorJsonContext.Default.TestPhaseStartResult, ct); Result<TestPhaseStartResult> accepted = await rpc.RequestAsync(MoShiroMethods.TestPhase, start, MoShiroJsonContext.Default.TestPhaseStartParams, MoShiroJsonContext.Default.TestPhaseStartResult, ct);
if (!accepted.TryGet(out TestPhaseStartResult? started, out FleetError? error) || !started.Accepted) if (!accepted.TryGet(out TestPhaseStartResult? started, out FleetError? error) || !started.Accepted)
{ {
throw new TestFailure(error?.Code.Value ?? FleetErrors.Unsupported.Value); throw new TestFailure(error?.Code.Value ?? FleetErrors.Unsupported.Value);
@@ -209,7 +214,7 @@ public sealed class TestRunner
} }
catch (BlockFaultException) catch (BlockFaultException)
{ {
throw new TestFailure(MonitorErrors.ProbeOffline.Value); throw new TestFailure(MoShiroErrors.ProbeOffline.Value);
} }
} }
else else
@@ -219,7 +224,7 @@ public sealed class TestRunner
long sent = stream.Running.Block.Produced; long sent = stream.Running.Block.Produced;
var end = new TestPhaseEndParams { TestId = test, Phase = index, StreamId = stream.Id, SentCount = sent }; var end = new TestPhaseEndParams { TestId = test, Phase = index, StreamId = stream.Id, SentCount = sent };
Result<TestPhaseEndResult> ended = await rpc.RequestAsync(MonitorMethods.TestPhaseEnd, end, MonitorJsonContext.Default.TestPhaseEndParams, MonitorJsonContext.Default.TestPhaseEndResult, ct, s_phaseSlack); Result<TestPhaseEndResult> ended = await rpc.RequestAsync(MoShiroMethods.TestPhaseEnd, end, MoShiroJsonContext.Default.TestPhaseEndParams, MoShiroJsonContext.Default.TestPhaseEndResult, ct, s_phaseSlack);
if (!ended.TryGet(out TestPhaseEndResult? finished, out FleetError? endError)) if (!ended.TryGet(out TestPhaseEndResult? finished, out FleetError? endError))
{ {
throw new TestFailure(endError.Code.Value); throw new TestFailure(endError.Code.Value);
@@ -254,7 +259,7 @@ public sealed class TestRunner
try try
{ {
await rpc.NotifyAsync(MonitorMethods.TestAbort, new TestAbortParams { TestId = test }, MonitorJsonContext.Default.TestAbortParams, CancellationToken.None); await rpc.NotifyAsync(MoShiroMethods.TestAbort, new TestAbortParams { TestId = test }, MoShiroJsonContext.Default.TestAbortParams, CancellationToken.None);
} }
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException) catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException)
{ {
@@ -1,20 +0,0 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>monitor-agent</AssemblyName>
<RootNamespace>Monitor.Agent</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Monitor.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\Monitor.Measurement\Monitor.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
</Project>
+101
View File
@@ -0,0 +1,101 @@
# Fleet:通用节点框架
「很多节点连到一个面板」这类产品都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置。Fleet 把这些做成与业务无关的库,MoShiro 是它的第一个使用者;产品只写业务。Fleet 不引用任何 `MoShiro.*`,也不出现产品的名字、端口和协议前缀,由 `ArchitectureTests` 在构建时检查。
```text
┌──────────────────── 产品层(MoShiro:Protocol · Measurement · Agent · Probe · Backend)────────────────────┐
└────────────────────────────────────────────┬─────────────────────────────────────────────────────────────┘
│ 只依赖 Fleet
┌───────────────────────────── Fleet ────────┴─────────────────────────────────────────────────────────────┐
│ 节点侧(只依赖 BCL,NativeAOT) 服务端侧(ASP.NET Core) │
│ Fleet.Core Result、退避、有界队列、ID、令牌哈希 Fleet.Server.Abstractions IFleetStore 等接口 │
│ Fleet.Identity 密钥、签名、证书、KeyStore Fleet.Server.Enrollment 加入令牌、注册、审批 │
│ Fleet.Rpc JSON-RPC 信封、路由、二进制帧 Fleet.Server.Link wss 网关、在线表、状态下发 │
│ Fleet.Link 会话、认证、重连、发件箱、状态同步、点对点 │
│ Fleet.Hosting 模块宿主、配置、日志、服务外壳 工具:fleet-enroll(一次性注册) │
│ Fleet.Enrollment.Client 注册流程(给 fleet-enroll 用) │
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
## 设计原则
1. **与业务无关**:产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
5. **更新不在 Fleet 里**:节点进程没有自更新能力。容器节点的签名更新由宿主机上的更新器完成,见 [`docs/deployment.md`](../../docs/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。
## 身份
| 对象 | 算法 | 保存位置 |
| --- | --- | --- |
| 节点身份密钥 | ECDSA P-256(BCL 自带) | `<identity_dir>/node.key`(0600);服务端只保存公钥 |
| 点对点 TLS 证书 | 自签名 ECDSA P-256 | `<identity_dir>/tls.pfx`;对端按 SPKI SHA-256 指纹校验 |
| 签名串 | `"fleet|v1|<purpose>|" + 各字段` | 带用途前缀,防止同一个签名在不同场景被复用 |
用途:`auth`(连接服务端)、`peer`(点对点)、`enroll`(注册时的持有证明)。产品可以追加自己的用途,不能占用 `fleet` 保留的。
## 注册
```text
管理员 ── 创建加入令牌 {labels, max_uses, expires_at, auto_approve} ──> 服务端
<── 令牌明文(只返回一次)+ 安装命令
节点:fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/myapp
├─ 生成身份密钥和 TLS 证书
├─ POST 注册端点 { join_token, name, pubkey, tls_spki, proof = 签名("enroll", 令牌哈希 + pubkey), node_version, meta }
│ 服务端:校验令牌 → uses+1 → 创建节点(active 或 pending)→ 触发产品的 IEnrollmentHook
└─ 写入 <identity_dir>/node.json { node_id, server, … },然后退出
```
- 同名节点已存在时,新注册进入 `pending`,管理员选择「接管」(旧记录换成新公钥,历史数据保留)或「作为新节点」。
- 节点状态:`pending`(能连接,但不下发状态、不接收上报)、`active`、`disabled`(拒绝连接)。
## RPC
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
| 保留方法 | 方向 | 说明 |
| --- | --- | --- |
| `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) |
| `fleet.hello` | 节点→服务端 | 版本、能力、本地策略摘要、当前时间 → 返回时钟偏差;产品的元数据也放在这里 |
| `fleet.heartbeat` | 节点→服务端 | 每 10 秒一次,产品可以附加计数 |
| `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` |
| `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` |
| `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 |
## 连接
```text
connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 → 正常运行
▲ │
└──── 指数退避重连(1 s → 60 s,带抖动;401 或 revoked 时停止重试)◀─ 断开
```
本地策略:`allow_methods`、`max_frame_bytes`、`max_inflight`。
**可靠发件箱**:产品 `EnqueueAsync` → 有界队列(按字节和条数;满时 `DropOldest` 或 `Reject`)→ 发送 → 服务端收件箱(`IInbox`)持久化后 `fleet.outbox.ack` → 移除。至少一次,按 `msg_id`(UUIDv7)去重;在途数据默认限 4 MiB;丢弃会累计缺口,通过 `OnGap` 通知产品。只在内存里,不落盘。
**期望状态同步**:服务端 `IStateProvider<TDoc>` 给每个节点算 `{version, doc}`,变化时 `NotifyChanged` 推送;节点 `StateHandler<TDoc>` 调和后返回 `{applied_version, rejected[]}`。下发的是**完整快照**,天然幂等,重连后服务端总会重发当前版本。`rejected[]` 带结构化原因(`unsupported`、`budget_exceeded`、`policy_denied`),面板可以直接展示。
## 点对点会话
```text
节点 A(发起方,如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent)
<── fleet.challenge ──
── fleet.auth(peer 用途,A 的私钥签名)──>
B 用服务端下发的对端公钥列表验签(经 state.sync 下发),之后复用同一套 Rpc:路由、二进制通道、发件箱
```
对端公钥和允许连接的节点由产品通过期望状态下发给监听方,Fleet 只管认证和会话,拓扑由产品决定;UDP、原始 TCP 数据面由产品自己实现,Fleet 只提供会话密钥派生和首帧 MAC 的辅助函数。
## 宿主与服务端抽象
- `ModuleHost`:按依赖顺序启动模块、相反顺序停止,隔离故障,重启时退避。
- `NodeConfig`:源生成 JSON 读取,环境变量覆盖,启动时校验。
- `ConfigStore`:常驻进程里**唯一**允许写文件的地方,只能写配置目录,写入原子替换、权限 0600;父目录不存在会创建。
- `ServiceShell`:SIGTERM、Ctrl+C,systemd `sd_notify`、OpenWrt procd(前台运行)和 Windows 服务。
- 服务端接口:`IFleetStore`(节点、令牌、公钥、状态版本)、`IStateProvider<TDoc>`、`IInbox`、`INodeEventSink`、`IEnrollmentHook`。MoShiro 把 `IFleetStore` 放进自己的 `moshiro.db`,和业务数据由同一个单写入者写入。
+1 -1
View File
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
namespace Fleet.Core; namespace Fleet.Core;
/// <summary>节点 ID(带类型前缀的 ULID)。Monitor 中的 AgentId 就是 NodeId。</summary> /// <summary>节点 ID(带类型前缀的 ULID)。MoShiro 中的 AgentId 就是 NodeId。</summary>
[JsonConverter(typeof(NodeIdJsonConverter))] [JsonConverter(typeof(NodeIdJsonConverter))]
public readonly record struct NodeId(string Value) public readonly record struct NodeId(string Value)
{ {
+2 -1
View File
@@ -243,7 +243,8 @@ public sealed class ConfigStore
throw new InvalidOperationException("config files must stay inside the config directory"); throw new InvalidOperationException("config files must stay inside the config directory");
} }
Directory.CreateDirectory(_directory); // 相对路径里的子目录(例如 update/)也要建:新建的命名卷里只有镜像自带的目录
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string temp = path + ".tmp"; string temp = path + ".tmp";
#pragma warning disable RS0030 // 纯采集边界的唯一豁免点:ConfigStore 只写配置目录,先写临时文件再原子替换 #pragma warning disable RS0030 // 纯采集边界的唯一豁免点:ConfigStore 只写配置目录,先写临时文件再原子替换
File.WriteAllBytes(temp, content); File.WriteAllBytes(temp, content);
+1 -1
View File
@@ -49,7 +49,7 @@ public sealed class FleetLinkOptions
public string NodeVersion { get; init; } = "0.0.0"; public string NodeVersion { get; init; } = "0.0.0";
/// <summary>框架之外的能力,例如 <c>monitor.v1</c> 与 <c>monitor.kinds:udp_echo,tcp_echo</c>。</summary> /// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:udp_echo,tcp_echo</c>。</summary>
public IReadOnlyList<string> Capabilities { get; init; } = []; public IReadOnlyList<string> Capabilities { get; init; } = [];
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary> /// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
+7 -1
View File
@@ -57,6 +57,11 @@ public sealed class PeerListener
public async Task RunAsync(CancellationToken ct) public async Task RunAsync(CancellationToken ct)
{ {
var listener = new TcpListener(_options.Listen); var listener = new TcpListener(_options.Listen);
if (_options.Listen.Address.Equals(IPAddress.IPv6Any))
{
listener.Server.DualMode = true;
}
listener.Start(); listener.Start();
Port = ((IPEndPoint)listener.LocalEndpoint).Port; Port = ((IPEndPoint)listener.LocalEndpoint).Port;
Started.TrySetResult(); Started.TrySetResult();
@@ -219,7 +224,8 @@ public static class PeerConnector
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync( 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[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
{ {
var client = new TcpClient { NoDelay = true }; TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient();
client.NoDelay = true;
PeerSockets.EnableKeepAlive(client.Client); PeerSockets.EnableKeepAlive(client.Client);
try try
{ {
+1 -1
View File
@@ -58,7 +58,7 @@ public sealed class HelloParams
[JsonPropertyName("now_utc_ms")] [JsonPropertyName("now_utc_ms")]
public long NowUtcMs { get; set; } public long NowUtcMs { get; set; }
/// <summary>产品自定义的元数据(例如 Monitor 的 tls_spki、probe_ports、local_limits 摘要)。</summary> /// <summary>产品自定义的元数据(例如 MoShiro 的 tls_spki、probe_ports、local_limits 摘要)。</summary>
[JsonPropertyName("meta")] [JsonPropertyName("meta")]
public JsonElement? Meta { get; set; } public JsonElement? Meta { get; set; }
} }
@@ -2,7 +2,7 @@ namespace Fleet.Server;
public sealed class FleetServerOptions public sealed class FleetServerOptions
{ {
/// <summary>节点 ID 的类型前缀,Monitor 使用 <c>agt</c>。</summary> /// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary>
public string NodeIdPrefix { get; set; } = "node"; public string NodeIdPrefix { get; set; } = "node";
public int MaxFrameBytes { get; set; } = 16 << 20; public int MaxFrameBytes { get; set; } = 16 << 20;
@@ -2,7 +2,7 @@ using Fleet.Core;
namespace Fleet.Server; namespace Fleet.Server;
/// <summary>Fleet 服务端需要的存储。产品可以自己实现(Monitor 把它放进 monitor.db)。</summary> /// <summary>Fleet 服务端需要的存储。产品可以自己实现(MoShiro 把它放进 moshiro.db)。</summary>
public interface IFleetStore public interface IFleetStore
{ {
ValueTask<JoinTokenRecord?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenSha256, CancellationToken ct); ValueTask<JoinTokenRecord?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenSha256, CancellationToken ct);
@@ -50,7 +50,7 @@ public sealed class FleetServerBuilder
return this; return this;
} }
/// <summary>注册产品的 RPC 方法(<c>monitor.*</c> 等)。处理函数通过 <see cref="IServiceProvider"/> 在调用时解析服务,避免构造期的循环依赖。</summary> /// <summary>注册产品的 RPC 方法(<c>moshiro.*</c> 等)。处理函数通过 <see cref="IServiceProvider"/> 在调用时解析服务,避免构造期的循环依赖。</summary>
public FleetServerBuilder MapMethods(Action<RpcRouter<NodeCallContext>, IServiceProvider> configure) public FleetServerBuilder MapMethods(Action<RpcRouter<NodeCallContext>, IServiceProvider> configure)
{ {
_routeConfigurators.Add(configure); _routeConfigurators.Add(configure);
+1 -1
View File
@@ -2,7 +2,7 @@ using System.Text.Json.Nodes;
using Fleet.Core; using Fleet.Core;
using Fleet.Enrollment.Client; using Fleet.Enrollment.Client;
// fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/monitor [--meta '{"probe_tcp_port":7443,...}'] [--version 1.0] // fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/moshiro [--meta '{"probe_tcp_port":7443,...}'] [--version 1.0]
// 一次性运行:生成身份、注册、写入身份目录,然后退出。 // 一次性运行:生成身份、注册、写入身份目录,然后退出。
string? Arg(string name) => args.SkipWhile(a => a != name).Skip(1).FirstOrDefault(); string? Arg(string name) => args.SkipWhile(a => a != name).Skip(1).FirstOrDefault();
@@ -1,17 +1,36 @@
<Project> <Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" /> <Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere" <Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" /> Condition="'$(UseAotAnywhere)' == 'true'" />
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<AssemblyName>fleet-enroll</AssemblyName> <AssemblyName>fleet-enroll</AssemblyName>
<RootNamespace>FleetEnroll</RootNamespace> <RootNamespace>FleetEnroll</RootNamespace>
<IsAotCompatible>true</IsAotCompatible> <IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<StripSymbols>true</StripSymbols>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
<OptimizationPreference>Size</OptimizationPreference>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<StackTraceSupport>false</StackTraceSupport>
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" /> <ProjectReference Include="..\..\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" />
</ItemGroup> </ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" /> <Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere" <Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" /> Condition="'$(UseAotAnywhere)' == 'true'" />
</Project> </Project>
+1322 -30
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -8,6 +8,10 @@
<EventSourceSupport>false</EventSourceSupport> <EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport> <DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport> <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<ServerGarbageCollection>false</ServerGarbageCollection> <ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection> <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup> </PropertyGroup>
+21 -14
View File
@@ -1,46 +1,53 @@
# Monitor Probe # MoShiro Probe
Probe 是网络测量的发起端,通常运行在家宽或其他需要观测的网络出口。它连接 Agent、执行线路任务,并把本端采集的原始数据交给 Agent 汇总上报。Probe 不直接连接 Backend,也不提供远程命令或文件管理功能。 Probe 是网络测量的发起端,通常运行在家宽或其他需要观测的网络出口。它连接 Backend(身份、Agent 列表、状态)和 Agent(测量),执行线路任务,并把本端采集的原始数据交给 Agent 汇总上报。Probe 不提供远程命令或文件管理功能。
## 工作流程 ## 工作流程
```text ```text
Backend ──任务与节点列表──> Agent <──测量会话──> Probe Backend ──任务──> Agent <──测量会话──> Probe
│ │ │ ▲ │ │
└──汇总并上报数据 <───┘ │ └──汇总并上报数据 <───┘ │
└─────Agent 列表、状态(Fleet 长连接)───────────┘
``` ```
Probe 使用面板生成的 `probe.json` 读取自身身份、首次连接用的 Agent 地址和本地测试限制。成功连接后,Agent 会同步可用节点列表;Probe 将列表缓存到同目录的 `agents.json`。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。 Probe 使用面板生成的 `probe.json` 读取自身身份、面板地址(`server`)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
## 构建 ## 构建
需要 .NET SDK 10。为应用 `backend/global.json` 的 SDK 设置,请从 `backend/` 目录构建: 需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建:
```sh ```sh
cd backend cd backend
dotnet build probe/src/Monitor.Probe -c Release dotnet build probe/src/MoShiro.Probe -c Release
``` ```
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配: 发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```sh ```sh
dotnet publish probe/src/Monitor.Probe -c Release -r <RID> -p:PublishAot=true dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
``` ```
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。 也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/deployment.md`](../../docs/deployment.md)。
## 配置与运行 ## 配置与运行
在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/monitor/probe.json`。随后运行: 在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/moshiro/probe.json`。随后运行:
```sh ```sh
monitor-probe --config /etc/monitor/probe.json moshiro-probe --config /etc/moshiro/probe.json
``` ```
不传 `--config` 时默认读取 `/etc/monitor/probe.json`。配置中的 `limits` 是本地硬上限,Backend 无法远程放宽;路由器等低性能设备可调低测试码率和包速率,避免 Probe 自身成为测量瓶颈。部署时可使用仓库的 `packaging/` 文件和安装脚本。 不传 `--config` 时默认读取 `/etc/moshiro/probe.json`。配置中的 `limits` 是本地硬上限,Backend 无法远程放宽;路由器等低性能设备可调低测试码率和包速率,避免 Probe 自身成为测量瓶颈。部署时可使用仓库的 `packaging/` 文件和安装脚本。
也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,自动选择 Linux amd64 或 arm64 镜像。容器默认读取 `/etc/moshiro/probe.json`,并在同一目录维护 `agents.json` 缓存,配置与缓存存放在命名 volume 中。建议使用宿主网络;当前独立 ICMP 目标任务由 Agent 执行。
## 设计说明 ## 设计说明
- Probe 只执行编译进程序的测量模块。它不认识 Backend,不落盘测量数据,也不能执行命令或自行更新。 - Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。
- 测量模块由 `backend/shared/Monitor.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。 - 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
- 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。 - 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
Docker 与 rootless/rootful Podman 共用 `packaging/compose/probe/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
@@ -6,16 +6,16 @@ using Fleet.Core;
using Fleet.Identity; using Fleet.Identity;
using Fleet.Link; using Fleet.Link;
using Fleet.Rpc; using Fleet.Rpc;
using Monitor.Measurement; using MoShiro.Measurement;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Probe; namespace MoShiro.Probe;
/// <summary>所有连接共用的东西。</summary> /// <summary>所有连接共用的东西。</summary>
public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log); public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
/// <summary> /// <summary>
/// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 取 Agent 列表 → 按 Agent 下发的 <c>line_task_set</c> 运行角色 → /// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 按 Agent 下发的 <c>line_task_set</c> 运行角色 →
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。 /// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。 /// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
/// </summary> /// </summary>
@@ -28,13 +28,13 @@ public sealed class AgentConnection
private readonly ConnectionEnv _env; private readonly ConnectionEnv _env;
private volatile ConnectionSession? _session; private volatile ConnectionSession? _session;
public AgentConnection(BootstrapAgent endpoint, ConnectionEnv env) public AgentConnection(KnownAgent endpoint, ConnectionEnv env)
{ {
Endpoint = endpoint; Endpoint = endpoint;
_env = env; _env = env;
} }
public BootstrapAgent Endpoint { get; } public KnownAgent Endpoint { get; }
public bool IsConnected => _session is not null; public bool IsConnected => _session is not null;
@@ -74,8 +74,20 @@ public sealed class AgentConnection
private async Task<string> RunOnceAsync(Backoff backoff, CancellationToken ct) private async Task<string> RunOnceAsync(Backoff backoff, CancellationToken ct)
{ {
AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time);
using CancellationTokenSource resolveCt = CancellationTokenSource.CreateLinkedTokenSource(ct, resolveTimeout.Token);
IPAddress[] addresses = IPAddress.TryParse(Endpoint.Host, out IPAddress? literal)
? [literal]
: await Dns.GetHostAddressesAsync(Endpoint.Host, family, resolveCt.Token);
IPAddress? address = addresses.FirstOrDefault(a => a.AddressFamily == family && !a.IsIPv4MappedToIPv6);
if (address is null)
{
throw new SocketException((int)SocketError.HostNotFound);
}
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync( (PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
Endpoint.Host, Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct); address.ToString(), Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
var remote = (IPEndPoint)info.Remote!; var remote = (IPEndPoint)info.Remote!;
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key); 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, []); using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
@@ -105,12 +117,11 @@ public sealed class AgentConnection
Task pump = PumpAsync(rpc, outbox, cts.Token); Task pump = PumpAsync(rpc, outbox, cts.Token);
try try
{ {
// 先同步时钟(块头里要带偏移),再取 Agent 列表;Agent 随后推送线路任务集 // 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token); await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
backoff.Reset(); backoff.Reset();
_session = session; _session = session;
_env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs)); _env.Log.Info("agent session up", ("agent", Endpoint.AgentId), ("offset_us", clock.OffsetUs));
await RefreshDirectoryAsync(rpc, cts.Token);
Task maintenance = MaintainAsync(rpc, session, clock, cts.Token); Task maintenance = MaintainAsync(rpc, session, clock, cts.Token);
Task finished = await Task.WhenAny(run, receive, pump, maintenance); Task finished = await Task.WhenAny(run, receive, pump, maintenance);
await cts.CancelAsync(); await cts.CancelAsync();
@@ -155,7 +166,7 @@ public sealed class AgentConnection
for (int i = 0; i < rounds; i++) for (int i = 0; i < rounds; i++)
{ {
long sent = _env.Clock.NowUs(); long sent = _env.Clock.NowUs();
Result<ClockResult> reply = await rpc.RequestAsync(MonitorMethods.Clock, new ClockParams { ProbeMonoUs = sent }, MonitorJsonContext.Default.ClockParams, MonitorJsonContext.Default.ClockResult, ct); Result<ClockResult> reply = await rpc.RequestAsync(MoShiroMethods.Clock, new ClockParams { ProbeMonoUs = sent }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, ct);
if (reply.TryGet(out ClockResult? value, out _)) if (reply.TryGet(out ClockResult? value, out _))
{ {
clock.Add(sent, value.AgentMonoUs, _env.Clock.NowUs()); clock.Add(sent, value.AgentMonoUs, _env.Clock.NowUs());
@@ -163,20 +174,6 @@ public sealed class AgentConnection
} }
} }
private async Task RefreshDirectoryAsync(RpcSession<ConnectionSession> rpc, CancellationToken ct)
{
Result<ProbeAgentsResult> list = await rpc.RequestAsync(
MonitorMethods.ProbeAgents, new ProbeAgentsParams { HaveVersion = _env.Directory.Version }, MonitorJsonContext.Default.ProbeAgentsParams, MonitorJsonContext.Default.ProbeAgentsResult, ct);
if (list.TryGet(out ProbeAgentsResult? agents, out FleetError? error))
{
_env.Directory.Apply(agents);
}
else
{
_env.Log.Warn("could not fetch the agent list", ("agent", Endpoint.AgentId), ("err", error.Code));
}
}
/// <summary>切块(每 10 秒)和时钟同步(每 30 秒)。</summary> /// <summary>切块(每 10 秒)和时钟同步(每 30 秒)。</summary>
private async Task MaintainAsync(RpcSession<ConnectionSession> rpc, ConnectionSession session, ClockEstimator clock, CancellationToken ct) private async Task MaintainAsync(RpcSession<ConnectionSession> rpc, ConnectionSession session, ClockEstimator clock, CancellationToken ct)
{ {
@@ -204,7 +201,7 @@ public sealed class AgentConnection
var result = new StateSyncResult { AppliedVersion = sync.Version }; var result = new StateSyncResult { AppliedVersion = sync.Version };
if (sync.Type == LineTaskSet.StateType) if (sync.Type == LineTaskSet.StateType)
{ {
LineTaskSet? set = sync.Doc.Deserialize(MonitorJsonContext.Default.LineTaskSet); LineTaskSet? set = sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet);
if (set is null) if (set is null)
{ {
return Result<StateSyncResult>.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed"); return Result<StateSyncResult>.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed");
@@ -216,27 +213,19 @@ public sealed class AgentConnection
return Result<StateSyncResult>.Ok(result); return Result<StateSyncResult>.Ok(result);
}) })
.On( .On(
MonitorMethods.TestPhase, MoShiroMethods.TestPhase,
MonitorJsonContext.Default.TestPhaseStartParams, MoShiroJsonContext.Default.TestPhaseStartParams,
MonitorJsonContext.Default.TestPhaseStartResult, MoShiroJsonContext.Default.TestPhaseStartResult,
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) }))) (session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
.On( .On(
MonitorMethods.TestPhaseEnd, MoShiroMethods.TestPhaseEnd,
MonitorJsonContext.Default.TestPhaseEndParams, MoShiroJsonContext.Default.TestPhaseEndParams,
MonitorJsonContext.Default.TestPhaseEndResult, MoShiroJsonContext.Default.TestPhaseEndResult,
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) })) async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
.OnNotify( .OnNotify(
MonitorMethods.TestAbort, MoShiroMethods.TestAbort,
MonitorJsonContext.Default.TestAbortParams, MoShiroJsonContext.Default.TestAbortParams,
async (session, abort, _) => await session.AbortAsync(abort.TestId)) async (session, abort, _) => await session.AbortAsync(abort.TestId))
.OnNotify(
MonitorMethods.ProbeAgentsChanged,
MonitorJsonContext.Default.ProbeAgentsResult,
(session, list, _) =>
{
session.Env.Directory.Apply(list);
return ValueTask.CompletedTask;
})
.OnNotify( .OnNotify(
FleetMethods.OutboxAck, FleetMethods.OutboxAck,
FleetRpcContext.Default.OutboxAckParams, FleetRpcContext.Default.OutboxAckParams,
@@ -1,13 +1,12 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Probe; namespace MoShiro.Probe;
/// <summary> /// <summary>
/// Probe 要连接的 Agent 列表。Probe 不认识 Backend:列表先取自 <c>probe.json</c> 的 bootstrap(或上一次保存的 <c>agents.json</c>), /// Probe 要连接的 Agent 列表:由面板作为期望状态(<c>probe_agents</c>)直接下发;面板连不上时用上一次保存的 <c>agents.json</c>。
/// 连上任意一个 Agent 之后,由它代为向 Backend 查询(<c>monitor.probe.agents</c>),之后的变化由 Agent 推送(<c>agents_changed</c>)。
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。 /// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
/// </summary> /// </summary>
public sealed class AgentDirectory public sealed class AgentDirectory
@@ -17,29 +16,25 @@ public sealed class AgentDirectory
private readonly object _gate = new(); private readonly object _gate = new();
private readonly ConfigStore _store; private readonly ConfigStore _store;
private readonly INodeLog _log; private readonly INodeLog _log;
private List<BootstrapAgent> _agents; private List<KnownAgent> _agents = [];
private long _version; private long _version;
public AgentDirectory(ConfigStore store, string configDirectory, ProbeFile file, INodeLog log) public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log)
{ {
_store = store; _store = store;
_log = log; _log = log;
_agents = [.. file.Bootstrap];
string cache = Path.Combine(configDirectory, FileName); string cache = Path.Combine(configDirectory, FileName);
if (File.Exists(cache)) if (File.Exists(cache))
{ {
try try
{ {
AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache); AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache);
if (saved.Agents.Count > 0)
{
_agents = saved.Agents; _agents = saved.Agents;
_version = saved.Version; _version = saved.Version;
} }
}
catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException) catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException)
{ {
_log.Warn("agents.json is unreadable, using the bootstrap list", ("err", ex.Message)); _log.Warn("agents.json is unreadable, waiting for the panel", ("err", ex.Message));
} }
} }
} }
@@ -58,7 +53,7 @@ public sealed class AgentDirectory
} }
} }
public IReadOnlyList<BootstrapAgent> Agents public IReadOnlyList<KnownAgent> Agents
{ {
get get
{ {
@@ -69,10 +64,22 @@ public sealed class AgentDirectory
} }
} }
/// <summary>应用 Agent 转来的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary> /// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
public void Apply(ProbeAgentsResult result) public void Apply(ProbeAgentList result)
{ {
var next = result.Agents.Select(a => new BootstrapAgent { AgentId = a.AgentId.Value, Host = a.Host, TcpPort = a.TcpPort, Spki = a.Spki }).ToList(); var next = new List<KnownAgent>();
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 });
}
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 });
}
}
lock (_gate) lock (_gate)
{ {
if (result.Version < _version || (result.Version == _version && Same(next, _agents))) if (result.Version < _version || (result.Version == _version && Same(next, _agents)))
@@ -97,9 +104,9 @@ public sealed class AgentDirectory
Changed?.Invoke(); Changed?.Invoke();
} }
private static bool Same(List<BootstrapAgent> a, List<BootstrapAgent> b) => private static bool Same(List<KnownAgent> a, List<KnownAgent> b) =>
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second)); a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(BootstrapAgent a, BootstrapAgent b) => public static bool Equal(KnownAgent a, KnownAgent b) =>
a.AgentId == b.AgentId && 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.Spki == b.Spki;
} }
@@ -1,13 +1,13 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Fleet.Link; using Fleet.Link;
using Monitor.Measurement; using MoShiro.Measurement;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Probe; namespace MoShiro.Probe;
/// <summary> /// <summary>
/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>monitor.clock</c> 往返取中点,在最近 8 次里取往返最短的一次 /// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>moshiro.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
/// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。 /// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
/// </summary> /// </summary>
public sealed class ClockEstimator public sealed class ClockEstimator
@@ -82,6 +82,6 @@ public sealed class FragmentShipper : IFragmentSink
} }
} }
_outbox.Enqueue(MonitorChannels.ColumnBlock, header.ChunkId.Value, RawChunkCodec.Encode(new RawChunk(header, streams))); _outbox.Enqueue(MoShiroChannels.ColumnBlock, header.ChunkId.Value, RawChunkCodec.Encode(new RawChunk(header, streams)));
} }
} }
@@ -0,0 +1,26 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>moshiro-probe</AssemblyName>
<RootNamespace>MoShiro.Probe</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<StripSymbols>true</StripSymbols>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MoShiro.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
</Project>
+196
View File
@@ -0,0 +1,196 @@
using Fleet.Core;
using Fleet.Hosting;
using System.Text.Json;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>
/// Probe 的组装点:到面板的长连接(身份、Agent 列表、状态)+ 每个 Agent 一个测量连接(认证、重连、角色)。没有全局状态,测试可以在同一进程里启动多个。
/// Agent 列表变化时只调和连接:新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
/// </summary>
public sealed class ProbeApp : IDisposable
{
public const string Version = MoShiro.Protocol.BuildInfo.Version;
private readonly ConnectionEnv _env;
private readonly Dictionary<string, Running> _connections = [];
private readonly SemaphoreSlim _changed = new(0);
private readonly INodeLog _log;
private readonly FleetLink _link;
public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log)
{
_log = log;
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey));
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
var options = new FleetLinkOptions
{
Server = new Uri(file.Server),
Identity = identity,
NodeId = new NodeId(file.ProbeId),
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),
};
var list = new StateHandler<ProbeAgentList>(
ProbeAgentList.StateType,
MoShiroJsonContext.Default.ProbeAgentList,
(version, doc, _) =>
{
directory.Apply(doc);
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
});
var router = new RpcRouter<LinkContext>()
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
update.WriteOffer(offer);
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
update.RequestApply();
return ValueTask.CompletedTask;
});
_link = new FleetLink(options, router, [list], new Outbox(1 << 20), time, log);
}
/// <summary>到面板的连接(测试和状态查看用)。</summary>
public FleetLink Link => _link;
public AgentDirectory Directory => _env.Directory;
/// <summary>某个 Agent 的会话是否已经建立(测试和状态查看用)。</summary>
public bool IsConnected(string agentId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected;
}
}
public AgentConnection? Connection(string agentId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) ? running.Connection : null;
}
}
public async Task RunAsync(CancellationToken ct)
{
if (_env.Directory.Agents.Count == 0)
{
_log.Info("no agents known yet, waiting for the panel to send the list");
}
Task link = _link.RunAsync(ct);
try
{
while (!ct.IsCancellationRequested)
{
await ReconcileAsync(_env.Directory.Agents, ct);
await _changed.WaitAsync(ct);
}
}
catch (OperationCanceledException)
{
// 停止
}
finally
{
await ReconcileAsync([], CancellationToken.None);
await Task.WhenAny(link, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
}
}
public void Dispose()
{
_env.Identity.Dispose();
_changed.Dispose();
}
private async Task ReconcileAsync(IReadOnlyList<KnownAgent> agents, CancellationToken ct)
{
var wanted = agents.ToDictionary(a => a.PathKey);
var stopped = new List<Running>();
lock (_connections)
{
foreach ((string id, Running running) in _connections.ToList())
{
if (!wanted.TryGetValue(id, out KnownAgent? endpoint) || !AgentDirectory.Equal(endpoint, running.Connection.Endpoint))
{
stopped.Add(running);
_connections.Remove(id);
}
}
}
foreach (Running running in stopped)
{
await running.StopAsync();
}
if (ct.IsCancellationRequested)
{
return;
}
lock (_connections)
{
foreach ((string id, KnownAgent endpoint) in wanted)
{
if (!_connections.ContainsKey(id))
{
_connections[id] = Running.Start(new AgentConnection(endpoint, _env));
}
}
}
}
private sealed class Running : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task _run = Task.CompletedTask;
private Running(AgentConnection connection)
{
Connection = connection;
}
public AgentConnection Connection { get; }
public static Running Start(AgentConnection connection)
{
var running = new Running(connection);
running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 没有及时退出:不再等
}
Dispose();
}
public void Dispose() => _cts.Dispose();
}
}
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MoShiro.Protocol;
namespace Monitor.Probe; namespace MoShiro.Probe;
/// <summary> /// <summary>
/// <c>probe.json</c>:面板 <c>POST /api/admin/probe/identity</c> 生成的文件(只能下载一次)。 /// <c>probe.json</c>:面板 <c>POST /api/admin/probe/identity</c> 生成的文件(只能下载一次)。
@@ -15,16 +16,22 @@ public sealed class ProbeFile
[JsonPropertyName("private_key")] [JsonPropertyName("private_key")]
public string PrivateKey { get; set; } = ""; public string PrivateKey { get; set; } = "";
/// <summary>首次连接用的 Agent;连上之后以 <c>agents.json</c>(最新的列表)为准。</summary> /// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
[JsonPropertyName("bootstrap")] [JsonPropertyName("server")]
public List<BootstrapAgent> Bootstrap { get; set; } = []; public string Server { get; set; } = "";
[JsonPropertyName("limits")] [JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new(); public ProbeLimits Limits { get; set; } = new();
} }
public sealed class BootstrapAgent public sealed class KnownAgent
{ {
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonIgnore]
public string PathKey => AgentId + "~" + IpVersion;
[JsonPropertyName("agent_id")] [JsonPropertyName("agent_id")]
public string AgentId { get; set; } = ""; public string AgentId { get; set; } = "";
@@ -54,12 +61,12 @@ public sealed class ProbeLimits
[JsonSerializable(typeof(AgentsCache))] [JsonSerializable(typeof(AgentsCache))]
public sealed partial class ProbeConfigContext : JsonSerializerContext; public sealed partial class ProbeConfigContext : JsonSerializerContext;
/// <summary><c>agents.json</c>:最近一次从 Agent 同步到的列表(配置,不是测量数据)。</summary> /// <summary><c>agents.json</c>:最近一次从面板同步到的 Agent 列表(配置,不是测量数据);面板连不上时 Probe 靠它继续测量。</summary>
public sealed class AgentsCache public sealed class AgentsCache
{ {
[JsonPropertyName("version")] [JsonPropertyName("version")]
public long Version { get; set; } public long Version { get; set; }
[JsonPropertyName("agents")] [JsonPropertyName("agents")]
public List<BootstrapAgent> Agents { get; set; } = []; public List<KnownAgent> Agents { get; set; } = [];
} }
@@ -1,6 +1,6 @@
using Fleet.Core; using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using Monitor.Probe; using MoShiro.Probe;
TimeProvider time = TimeProvider.System; TimeProvider time = TimeProvider.System;
INodeLog log = new ConsoleNodeLog(time); INodeLog log = new ConsoleNodeLog(time);
@@ -11,7 +11,7 @@ if (PlatformCheck.VerifyCrypto() is { } problem)
return 1; return 1;
} }
string configPath = "/etc/monitor/probe.json"; string configPath = "/etc/moshiro/probe.json";
for (int i = 0; i < args.Length - 1; i++) for (int i = 0; i < args.Length - 1; i++)
{ {
if (args[i] == "--config") if (args[i] == "--config")
@@ -24,9 +24,9 @@ ProbeFile file;
try try
{ {
file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile); file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile);
if (file.ProbeId.Length == 0 || file.PrivateKey.Length == 0) if (file.ProbeId.Length == 0 || file.PrivateKey.Length == 0 || !Uri.TryCreate(file.Server, UriKind.Absolute, out _))
{ {
throw new InvalidOperationException("probe.json needs probe_id and private_key (download it from the panel: POST /api/admin/probe/identity)"); throw new InvalidOperationException("probe.json needs probe_id, private_key and server (download it from the panel: Probes → Create)");
} }
} }
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException) catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException)
@@ -1,20 +0,0 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>monitor-probe</AssemblyName>
<RootNamespace>Monitor.Probe</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Monitor.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\Monitor.Measurement\Monitor.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(RuntimeIdentifier)' == 'linux-x64' Or '$(RuntimeIdentifier)' == 'linux-arm64' Or '$(RuntimeIdentifier)' == 'linux-arm' Or '$(RuntimeIdentifier)' == 'linux-musl-x64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm64' Or '$(RuntimeIdentifier)' == 'linux-musl-arm' Or '$(RuntimeIdentifier)' == 'osx-x64' Or '$(RuntimeIdentifier)' == 'osx-arm64' Or '$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == 'win-arm64'" />
</Project>
-154
View File
@@ -1,154 +0,0 @@
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
using Monitor.Measurement;
namespace Monitor.Probe;
/// <summary>
/// Probe 的组装点:Agent 列表 → 每个 Agent 一个连接(认证、重连、角色)。没有全局状态,测试可以在同一进程里启动多个。
/// 列表变化时只调和连接:新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
/// </summary>
public sealed class ProbeApp : IDisposable
{
public const string Version = "0.1.0";
private readonly ConnectionEnv _env;
private readonly Dictionary<string, Running> _connections = [];
private readonly SemaphoreSlim _changed = new(0);
private readonly INodeLog _log;
public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log)
{
_log = log;
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, file, log);
NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey));
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
}
public AgentDirectory Directory => _env.Directory;
/// <summary>某个 Agent 的会话是否已经建立(测试和状态查看用)。</summary>
public bool IsConnected(string agentId)
{
lock (_connections)
{
return _connections.TryGetValue(agentId, out Running? running) && running.Connection.IsConnected;
}
}
public AgentConnection? Connection(string agentId)
{
lock (_connections)
{
return _connections.TryGetValue(agentId, out Running? running) ? running.Connection : null;
}
}
public async Task RunAsync(CancellationToken ct)
{
if (_env.Directory.Agents.Count == 0)
{
_log.Warn("no agents known yet: probe.json has an empty bootstrap list");
}
try
{
while (!ct.IsCancellationRequested)
{
await ReconcileAsync(_env.Directory.Agents, ct);
await _changed.WaitAsync(ct);
}
}
catch (OperationCanceledException)
{
// 停止
}
finally
{
await ReconcileAsync([], CancellationToken.None);
}
}
public void Dispose()
{
_env.Identity.Dispose();
_changed.Dispose();
}
private async Task ReconcileAsync(IReadOnlyList<BootstrapAgent> agents, CancellationToken ct)
{
var wanted = agents.ToDictionary(a => a.AgentId);
var stopped = new List<Running>();
lock (_connections)
{
foreach ((string id, Running running) in _connections.ToList())
{
if (!wanted.TryGetValue(id, out BootstrapAgent? endpoint) || !AgentDirectory.Equal(endpoint, running.Connection.Endpoint))
{
stopped.Add(running);
_connections.Remove(id);
}
}
}
foreach (Running running in stopped)
{
await running.StopAsync();
}
if (ct.IsCancellationRequested)
{
return;
}
lock (_connections)
{
foreach ((string id, BootstrapAgent endpoint) in wanted)
{
if (!_connections.ContainsKey(id))
{
_connections[id] = Running.Start(new AgentConnection(endpoint, _env));
}
}
}
}
private sealed class Running : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task _run = Task.CompletedTask;
private Running(AgentConnection connection)
{
Connection = connection;
}
public AgentConnection Connection { get; }
public static Running Start(AgentConnection connection)
{
var running = new Running(connection);
running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 没有及时退出:不再等
}
Dispose();
}
public void Dispose() => _cts.Dispose();
}
}
@@ -5,7 +5,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\src\Monitor.Probe\Monitor.Probe.csproj" /> <ProjectReference Include="..\..\src\MoShiro.Probe\MoShiro.Probe.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
@@ -1,11 +1,36 @@
using Fleet.Core; using Fleet.Core;
using Fleet.Hosting; using Fleet.Hosting;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Probe.Tests; namespace MoShiro.Probe.Tests;
public sealed class ProbeTests public sealed class ProbeTests
{ {
[Fact]
public void AgentDirectory_DualStack_PersistsBothPaths_AndRemovingIpv6KeepsIpv4()
{
string directory = NewDirectory();
try
{
var agents = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
ProbeAgentList list = List(1, "agt_dual");
list.Agents[0].Ipv6Host = "2001:db8::1";
agents.Apply(list);
Assert.Equal(new[] { "ipv4", "ipv6" }, agents.Agents.Select(a => a.IpVersion));
var reloaded = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Equal(2, reloaded.Agents.Count);
Assert.Equal("2001:db8::1", reloaded.Agents[1].Host);
list.Version = 2;
list.Agents[0].Ipv6Host = null;
reloaded.Apply(list);
Assert.Equal("ipv4", Assert.Single(reloaded.Agents).IpVersion);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact] [Fact]
public void ClockEstimator_UsesTheSampleWithTheShortestRoundTrip() public void ClockEstimator_UsesTheSampleWithTheShortestRoundTrip()
{ {
@@ -30,23 +55,22 @@ public sealed class ProbeTests
return path; return path;
} }
private static ProbeAgentsResult List(long version, params string[] ids) => new() private static ProbeAgentList List(long version, params string[] ids) => new()
{ {
Version = version, 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, Spki = "c3BraQ==" })],
}; };
[Fact] [Fact]
public void AgentDirectory_StartsFromBootstrap_IgnoresStaleLists_PersistsChanges_AndReloadsThem() public void AgentDirectory_StartsEmpty_IgnoresStaleLists_PersistsChanges_AndReloadsThem()
{ {
string directory = NewDirectory(); string directory = NewDirectory();
try try
{ {
var file = new ProbeFile { ProbeId = "prb_x", Bootstrap = [new BootstrapAgent { AgentId = "agt_boot", Host = "boot.example", TcpPort = 7443, Spki = "c3BraQ==" }] }; var directoryA = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
var directoryA = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance);
int changes = 0; int changes = 0;
directoryA.Changed += () => changes++; directoryA.Changed += () => changes++;
Assert.Equal(["agt_boot"], directoryA.Agents.Select(a => a.AgentId)); Assert.Empty(directoryA.Agents);
directoryA.Apply(List(5, "agt_1", "agt_2")); directoryA.Apply(List(5, "agt_1", "agt_2"));
Assert.Equal(1, changes); Assert.Equal(1, changes);
@@ -57,8 +81,8 @@ public sealed class ProbeTests
Assert.Equal(1, changes); Assert.Equal(1, changes);
Assert.Equal(5, directoryA.Version); Assert.Equal(5, directoryA.Version);
// The next start uses the saved list, not the bootstrap one // The next start uses the saved list while the panel is not reachable yet
var directoryB = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance); var directoryB = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Equal(["agt_1", "agt_2"], directoryB.Agents.Select(a => a.AgentId)); Assert.Equal(["agt_1", "agt_2"], directoryB.Agents.Select(a => a.AgentId));
Assert.Equal(5, directoryB.Version); Assert.Equal(5, directoryB.Version);
} }
@@ -69,15 +93,30 @@ public sealed class ProbeTests
} }
[Fact] [Fact]
public void AgentDirectory_FallsBackToBootstrap_WhenTheSavedFileIsCorrupt() public void AgentDirectory_StartsEmpty_WhenTheSavedFileIsCorrupt()
{ {
string directory = NewDirectory(); string directory = NewDirectory();
try try
{ {
new ConfigStore(directory).Write(AgentDirectory.FileName, "{ not json"u8.ToArray()); new ConfigStore(directory).Write(AgentDirectory.FileName, "{ not json"u8.ToArray());
var file = new ProbeFile { Bootstrap = [new BootstrapAgent { AgentId = "agt_boot", Host = "h", TcpPort = 1, Spki = "AA==" }] }; var loaded = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
var loaded = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance); Assert.Empty(loaded.Agents);
Assert.Equal(["agt_boot"], loaded.Agents.Select(a => a.AgentId)); }
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ConfigStore_CreatesSubdirectories_ForFreshVolumes()
{
// 新建的命名卷里没有 update/:面板转来的更新清单要能写进去
string directory = NewDirectory();
try
{
new ConfigStore(directory).Write("update/offer.manifest", "{}"u8.ToArray());
Assert.True(File.Exists(Path.Combine(directory, "update", "offer.manifest")));
} }
finally finally
{ {
@@ -1,7 +1,7 @@
using System.Net; using System.Net;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>积木的公共部分:环境、参数、把行切成列块交出去。</summary> /// <summary>积木的公共部分:环境、参数、把行切成列块交出去。</summary>
public abstract class BlockBase : IBlock public abstract class BlockBase : IBlock
@@ -1,6 +1,6 @@
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary> /// <summary>
/// 一种采集方法(Kind)的两个角色:拨号方(Probe)和监听方(Agent),以及它能不能作为目标任务(Agent 自己探测一个地址,没有对端)运行。 /// 一种采集方法(Kind)的两个角色:拨号方(Probe)和监听方(Agent),以及它能不能作为目标任务(Agent 自己探测一个地址,没有对端)运行。
@@ -1,7 +1,7 @@
using System.Net.Sockets; using System.Net.Sockets;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>批量传输的收发循环(两端共用)。</summary> /// <summary>批量传输的收发循环(两端共用)。</summary>
internal static class BulkIo internal static class BulkIo
@@ -1,8 +1,8 @@
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary> /// <summary>
/// 回显积木的行表(拨号方,行的主导方):发出时记 size、t1,回复到达时按序号回填 t4,到点把已经稳定的行切成列块。 /// 回显积木的行表(拨号方,行的主导方):发出时记 size、t1,回复到达时按序号回填 t4,到点把已经稳定的行切成列块。
@@ -1,9 +1,10 @@
using System.Net.NetworkInformation; using System.Net.NetworkInformation;
using System.Net.Sockets; using System.Net.Sockets;
using System.Security.Authentication; using System.Security.Authentication;
using Monitor.Protocol; using Fleet.Core;
using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>什么也不做的监听方:connect 的另一端就是 TCP 监听器本身(接受连接、认证失败就关闭),不需要积木,但需要占一个流编号。</summary> /// <summary>什么也不做的监听方:connect 的另一端就是 TCP 监听器本身(接受连接、认证失败就关闭),不需要积木,但需要占一个流编号。</summary>
public sealed class IdleListener : BlockBase public sealed class IdleListener : BlockBase
@@ -306,7 +307,8 @@ public sealed class IcmpEchoSolo : BlockBase
TimeSpan interval = TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")); TimeSpan interval = TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz"));
int size = (int)Spec.Int("size"); int size = (int)Spec.Int("size");
byte[] payload = new byte[Math.Max(0, size - 28)]; byte[] payload = new byte[Math.Max(0, size - 28)];
using var ping = new Ping(); using LinuxIcmpEcho? linuxPing = OperatingSystem.IsLinux() ? LinuxIcmpEcho.TryCreate(Env.PeerAddress!, payload.Length) : null;
using Ping? ping = linuxPing is null ? new Ping() : null;
try try
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
@@ -315,8 +317,10 @@ public sealed class IcmpEchoSolo : BlockBase
long? t4 = null; long? t4 = null;
try try
{ {
PingReply reply = await ping.SendPingAsync(Env.PeerAddress!, TimeSpan.FromSeconds(2), payload, null, ct); bool receivedReply = linuxPing is not null
if (reply.Status == IPStatus.Success) ? await linuxPing.SendAsync(TimeSpan.FromSeconds(2), ct)
: (await ping!.SendPingAsync(Env.PeerAddress!, TimeSpan.FromSeconds(2), payload, null, ct)).Status == IPStatus.Success;
if (receivedReply)
{ {
t4 = Env.Clock.NowUs(); t4 = Env.Clock.NowUs();
} }
@@ -325,6 +329,11 @@ public sealed class IcmpEchoSolo : BlockBase
{ {
// 发送失败(例如本机没有路由):记为丢失 // 发送失败(例如本机没有路由):记为丢失
} }
catch (PlatformNotSupportedException) when (OperatingSystem.IsLinux())
{
Env.Log.Warn("ICMP permission unavailable", ("hint", "allow the mapped process GID in net.ipv4.ping_group_range, or grant effective NET_RAW"));
throw;
}
long key = _log.Store.Append(); long key = _log.Store.Append();
_log.Store.Set(key, _seq, key); _log.Store.Set(key, _seq, key);
@@ -1,8 +1,8 @@
using System.Diagnostics; using System.Diagnostics;
using System.Net; using System.Net;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary> /// <summary>
/// 单向数据流(UDP train),监听方(Agent,行的主导方):按声明的码率均匀发包,发出的每个包 (seq, size, t_send) 记在按序号的记录里, /// 单向数据流(UDP train),监听方(Agent,行的主导方):按声明的码率均匀发包,发出的每个包 (seq, size, t_send) 记在按序号的记录里,
@@ -1,9 +1,9 @@
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary> /// <summary>
/// 数据面会话:<c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)。两端的数据面包都用它认证。 /// 数据面会话:<c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)。两端的数据面包都用它认证。
@@ -1,4 +1,4 @@
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;Probe 和 Agent 各自一个,块头里的时钟锚点把它对应到 UTC。</summary> /// <summary>节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;Probe 和 Agent 各自一个,块头里的时钟锚点把它对应到 UTC。</summary>
public sealed class MonoClock public sealed class MonoClock
@@ -1,6 +1,6 @@
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary> /// <summary>
/// 一侧记录的行:只含这一侧拥有的列(见 <see cref="StreamSpec.ColumnsOf"/>),按连接键(通常是 <c>seq</c>)索引的环形缓冲, /// 一侧记录的行:只含这一侧拥有的列(见 <see cref="StreamSpec.ColumnsOf"/>),按连接键(通常是 <c>seq</c>)索引的环形缓冲,
@@ -1,4 +1,4 @@
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。</summary> /// <summary>一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。</summary>
public sealed class RunningBlock : IDisposable public sealed class RunningBlock : IDisposable
@@ -1,8 +1,8 @@
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>节点的本地硬上限(写在本机配置里,Backend 无法修改)。</summary> /// <summary>节点的本地硬上限(写在本机配置里,Backend 无法修改)。</summary>
public sealed class NodeLimits public sealed class NodeLimits
@@ -1,8 +1,8 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>Agent 单调时钟(微秒)与 UTC(毫秒)的对应关系:块头里的时钟锚点。</summary> /// <summary>Agent 单调时钟(微秒)与 UTC(毫秒)的对应关系:块头里的时钟锚点。</summary>
public readonly record struct ClockAnchor(long AgentMonoUs, long UtcMs); public readonly record struct ClockAnchor(long AgentMonoUs, long UtcMs);
@@ -69,6 +69,7 @@ public static class ChunkFinalizer
Phase = source.Phase, Phase = source.Phase,
AgentId = agent, AgentId = agent,
ProbeId = source.ProbeId, ProbeId = source.ProbeId,
IpVersion = source.IpVersion,
Target = null, Target = null,
Params = new Dictionary<string, JsonElement>(resolvedParams), Params = new Dictionary<string, JsonElement>(resolvedParams),
Port = port, Port = port,
@@ -1,8 +1,8 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>Agent 一侧为合并保留的按键记录(由正在运行的积木提供)。</summary> /// <summary>Agent 一侧为合并保留的按键记录(由正在运行的积木提供)。</summary>
public interface IRecordSource public interface IRecordSource
@@ -29,7 +29,7 @@ public static class FragmentMerger
StreamSpec? streamSpec = spec.FindStream(stream.Name); StreamSpec? streamSpec = spec.FindStream(stream.Name);
if (streamSpec is null) if (streamSpec is null)
{ {
return Result<IReadOnlyList<RawStream>>.Fail(MonitorErrors.ValidationFailed, $"stream {stream.Name} is not declared by {spec.Id}"); return Result<IReadOnlyList<RawStream>>.Fail(MoShiroErrors.ValidationFailed, $"stream {stream.Name} is not declared by {spec.Id}");
} }
Result<RawStream> merged = Join(streamSpec, stream, fragment.Header, records.Records(streamId, stream.Name)); Result<RawStream> merged = Join(streamSpec, stream, fragment.Header, records.Records(streamId, stream.Name));
@@ -71,7 +71,7 @@ public static class FragmentMerger
RawColumn? key = probe.Find(spec.Key); RawColumn? key = probe.Find(spec.Key);
if (key is null || probeColumns.Any(c => probe.Find(c.Name) is null)) if (key is null || probeColumns.Any(c => probe.Find(c.Name) is null))
{ {
return Result<RawStream>.Fail(MonitorErrors.ValidationFailed, $"a fragment of stream {spec.Name} needs the columns {string.Join(", ", new[] { spec.Key }.Concat(probeColumns.Select(c => c.Name)))}"); return Result<RawStream>.Fail(MoShiroErrors.ValidationFailed, $"a fragment of stream {spec.Name} needs the columns {string.Join(", ", new[] { spec.Key }.Concat(probeColumns.Select(c => c.Name)))}");
} }
return spec.Driver == Side.Probe ? JoinOnProbeRows(spec, probe, key, agent) : JoinOnAgentRows(spec, probe, key, header, agent); return spec.Driver == Side.Probe ? JoinOnProbeRows(spec, probe, key, agent) : JoinOnAgentRows(spec, probe, key, header, agent);
@@ -118,7 +118,7 @@ public static class FragmentMerger
{ {
if (!TryGetRange(header, out uint from, out uint to)) if (!TryGetRange(header, out uint from, out uint to))
{ {
return Result<RawStream>.Fail(MonitorErrors.ValidationFailed, $"a fragment of stream {spec.Name} must declare its sequence range"); return Result<RawStream>.Fail(MoShiroErrors.ValidationFailed, $"a fragment of stream {spec.Name} must declare its sequence range");
} }
// 行取自 Agent 的记录:范围内发出过的每一个包都有一行,Probe 没有报告到达的就是丢失 // 行取自 Agent 的记录:范围内发出过的每一个包都有一行,Probe 没有报告到达的就是丢失
@@ -3,8 +3,8 @@
<IsAotCompatible>true</IsAotCompatible> <IsAotCompatible>true</IsAotCompatible>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="Monitor.Measurement.Tests" /> <InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../BannedSymbols.Node.txt" /> <AdditionalFiles Include="$(MSBuildThisFileDirectory)../../BannedSymbols.Node.txt" />
<ProjectReference Include="..\Monitor.Protocol\Monitor.Protocol.csproj" /> <ProjectReference Include="..\MoShiro.Protocol\MoShiro.Protocol.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,84 @@
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
namespace MoShiro.Measurement;
/// <summary>Linux ping sockets permit ICMP echo without CAP_NET_RAW when ping_group_range allows the process group.</summary>
internal sealed class LinuxIcmpEcho : IDisposable
{
private readonly Socket _socket;
private readonly byte[] _request;
private readonly byte[] _response;
private readonly byte _replyType;
private ushort _sequence;
private LinuxIcmpEcho(IPAddress address, int payloadSize)
{
bool ipv6 = address.AddressFamily == AddressFamily.InterNetworkV6;
_socket = new Socket(address.AddressFamily, SocketType.Dgram, ipv6 ? ProtocolType.IcmpV6 : ProtocolType.Icmp);
try
{
_socket.Connect(new IPEndPoint(address, 0));
}
catch
{
_socket.Dispose();
throw;
}
_request = new byte[8 + payloadSize];
_request[0] = ipv6 ? (byte)128 : (byte)8;
_replyType = ipv6 ? (byte)129 : (byte)0;
_response = new byte[_request.Length + 64];
// The Linux ping socket supplies the identifier and checksum.
Random.Shared.NextBytes(_request.AsSpan(8));
}
public static LinuxIcmpEcho? TryCreate(IPAddress address, int payloadSize)
{
try
{
return new LinuxIcmpEcho(address, payloadSize);
}
catch (SocketException)
{
// Preserve System.Net.Ping's raw-socket path for hosts that allow NET_RAW instead.
return null;
}
}
public async ValueTask<bool> SendAsync(TimeSpan timeout, CancellationToken ct)
{
ushort sequence = unchecked(++_sequence);
BinaryPrimitives.WriteUInt16BigEndian(_request.AsSpan(6), sequence);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(timeout);
try
{
await _socket.SendAsync(_request, SocketFlags.None, deadline.Token);
while (true)
{
int count = await _socket.ReceiveAsync(_response, SocketFlags.None, deadline.Token);
if (IsReply(_response.AsSpan(0, count), _request, _replyType, sequence))
{
return true;
}
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return false;
}
catch (SocketException)
{
return false;
}
}
internal static bool IsReply(ReadOnlySpan<byte> reply, ReadOnlySpan<byte> request, byte replyType, ushort sequence) =>
reply.Length == request.Length && reply.Length >= 8 && reply[0] == replyType && reply[1] == 0 &&
BinaryPrimitives.ReadUInt16BigEndian(reply[6..]) == sequence && reply[8..].SequenceEqual(request[8..]);
public void Dispose() => _socket.Dispose();
}
@@ -5,9 +5,9 @@ using System.Net.Sockets;
using System.Security.Authentication; using System.Security.Authentication;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>拨号方的一条数据面 TCP 连接(已经完成 TLS 和首帧认证)。</summary> /// <summary>拨号方的一条数据面 TCP 连接(已经完成 TLS 和首帧认证)。</summary>
public sealed class TcpLink : IDisposable public sealed class TcpLink : IDisposable
@@ -39,7 +39,7 @@ public sealed class TcpLink : IDisposable
/// </summary> /// </summary>
public static async Task<TcpLink> OpenAsync(BlockEnv env, int port, bool tls, uint streamId, bool sendAuth, CancellationToken ct) public static async Task<TcpLink> OpenAsync(BlockEnv env, int port, bool tls, uint streamId, bool sendAuth, CancellationToken ct)
{ {
var client = new TcpClient { NoDelay = true }; var client = new TcpClient(env.PeerAddress!.AddressFamily) { NoDelay = true };
try try
{ {
long start = env.Clock.NowUs(); long start = env.Clock.NowUs();
@@ -153,6 +153,11 @@ public sealed class TcpAcceptor
foreach (int port in _ports) foreach (int port in _ports)
{ {
var listener = new TcpListener(_bind, port); var listener = new TcpListener(_bind, port);
if (_bind.Equals(IPAddress.IPv6Any))
{
listener.Server.DualMode = true;
}
listener.Start(128); listener.Start(128);
_listeners.Add(listener); _listeners.Add(listener);
bound.Add(((IPEndPoint)listener.LocalEndpoint).Port); bound.Add(((IPEndPoint)listener.LocalEndpoint).Port);
@@ -1,8 +1,8 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Net.Sockets; using System.Net.Sockets;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>内核对一条 TCP 连接的采样(Linux <c>TCP_INFO</c>)。</summary> /// <summary>内核对一条 TCP 连接的采样(Linux <c>TCP_INFO</c>)。</summary>
public readonly record struct TcpInfoSample(long SrttUs, long RttVarUs, long Cwnd, long TotalRetrans, long SegsOut, long BytesAcked); public readonly record struct TcpInfoSample(long SrttUs, long RttVarUs, long Cwnd, long TotalRetrans, long SegsOut, long BytesAcked);
@@ -2,9 +2,9 @@ using System.Collections.Concurrent;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using Fleet.Core; using Fleet.Core;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement; namespace MoShiro.Measurement;
/// <summary>一个通过校验的 UDP 包,交给按 <c>stream_id</c> 登记的处理函数。处理函数在接收循环里同步执行,不能阻塞。</summary> /// <summary>一个通过校验的 UDP 包,交给按 <c>stream_id</c> 登记的处理函数。处理函数在接收循环里同步执行,不能阻塞。</summary>
public readonly ref struct UdpDelivery public readonly ref struct UdpDelivery
@@ -0,0 +1,154 @@
using System.Globalization;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>诊断采集进程所在的网络环境。宿主网络容器看到的是主机;桥接容器看到自己的网络环境。</summary>
public static class Ipv6Diagnostics
{
public static Ipv6Diagnostic Sample(bool? listening = null, string? networkScope = null, string? hostProcRoot = null)
{
bool supported = Socket.OSSupportsIPv6;
bool? address = null;
bool? route = null;
if (supported)
{
if (OperatingSystem.IsLinux())
{
address = ParseAddresses(Read("/proc/net/if_inet6"));
route = ParseRoutes(Read("/proc/net/ipv6_route"));
}
else if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
{
try
{
address = false;
route = false;
foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces())
{
if (adapter.OperationalStatus != OperationalStatus.Up || adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback)
{
continue;
}
IPInterfaceProperties properties = adapter.GetIPProperties();
foreach (UnicastIPAddressInformation unicast in properties.UnicastAddresses)
{
if (IsGlobal(unicast.Address))
{
address = true;
}
}
foreach (GatewayIPAddressInformation gateway in properties.GatewayAddresses)
{
if (gateway.Address.AddressFamily == AddressFamily.InterNetworkV6 && !gateway.Address.Equals(IPAddress.IPv6Any))
{
route = true;
}
}
}
}
catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException)
{
address = null;
route = null;
}
}
}
string scope = networkScope is "host" or "container" ? networkScope
: File.Exists("/run/.containerenv") || File.Exists("/.dockerenv") ? "container" : "host";
bool? hostAddress = scope == "host" ? address : null;
bool? hostRoute = scope == "host" ? route : null;
if (scope == "container" && OperatingSystem.IsLinux() && hostProcRoot is not null)
{
// /host/proc/net 通常指向当前进程的网络空间;宿主 PID 1 才是宿主网络。
hostAddress = ParseAddresses(Read(Path.Combine(hostProcRoot, "1/net/if_inet6")));
hostRoute = ParseRoutes(Read(Path.Combine(hostProcRoot, "1/net/ipv6_route")));
}
Ipv6Diagnostic result = Evaluate(supported, listening, address, route);
result.NetworkScope = scope;
result.HostHasGlobalAddress = hostAddress;
result.HostHasDefaultRoute = hostRoute;
if (scope == "container" && address == false && hostAddress == true)
{
result.State = "container_network_not_ready";
}
return result;
}
public static Ipv6Diagnostic Evaluate(bool supported, bool? listening, bool? address, bool? route) => new()
{
SocketSupported = supported,
Listening = listening,
HasGlobalAddress = address,
HasDefaultRoute = route,
State = !supported ? "unsupported" : address == false ? "no_global_address" : listening == false ? "not_listening"
: route == false ? "no_default_route" : address is null || route is null ? "unknown" : "ready",
};
public static bool? ParseAddresses(string? text)
{
if (text is null)
{
return null;
}
foreach (string line in text.Split('\n'))
{
string[] fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Length != 6 || fields[0].Length != 32 || fields[0][0] is not ('2' or '3') || fields[3] != "00")
{
continue;
}
// tentative、DAD failed 和 deprecated 地址不能作为就绪的公网地址。
if (uint.TryParse(fields[4], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint flags) && (flags & 0x68) == 0)
{
return true;
}
}
return false;
}
public static bool? ParseRoutes(string? text)
{
if (text is null)
{
return null;
}
foreach (string line in text.Split('\n'))
{
string[] fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Length == 10 && fields[0] == "00000000000000000000000000000000" && fields[1] == "00" && fields[9] != "lo"
&& uint.TryParse(fields[8], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint flags) && (flags & 1) != 0 && (flags & 0x200) == 0)
{
return true;
}
}
return false;
}
private static bool IsGlobal(IPAddress address) => address.AddressFamily == AddressFamily.InterNetworkV6 && (address.GetAddressBytes()[0] & 0xE0) == 0x20;
private static string? Read(string path)
{
try
{
return File.Exists(path) ? File.ReadAllText(path) : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
}
@@ -0,0 +1,81 @@
using System.Runtime.InteropServices;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 读出本机的系统名和架构,只用于在面板上展示。故意只取粗粒度的信息:
/// 「Debian 12」「Windows 11」「x64」,不读主机名、内核版本、CPU 型号。
/// </summary>
public static class PlatformDetector
{
public static PlatformInfo Detect()
{
PlatformInfo info = Detect(File.Exists("/etc/os-release") ? File.ReadAllLines("/etc/os-release") : []);
info.Version = Empty(BuildInfo.Version);
info.Tag = Empty(BuildInfo.Tag);
info.Commit = Empty(BuildInfo.Commit);
info.BuiltAt = Empty(BuildInfo.BuiltAt);
return info;
}
private static string? Empty(string value) => value.Length == 0 ? null : value;
/// <summary>`osRelease` 是 /etc/os-release 的各行(Linux 之外不用)。拆出来是为了好测。</summary>
internal static PlatformInfo Detect(IEnumerable<string> osRelease)
{
string arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "x64",
Architecture.Arm64 => "arm64",
Architecture.Arm => "arm",
Architecture.X86 => "x86",
var other => other.ToString().ToLowerInvariant(),
};
if (OperatingSystem.IsWindows())
{
// Windows 11 的版本号还是 10.0,靠内部版本号(22000 起)区分
string name = Environment.OSVersion.Version.Build >= 22000 ? "Windows 11" : "Windows 10";
return new PlatformInfo { Os = "windows", Name = name, Arch = arch };
}
if (OperatingSystem.IsMacOS())
{
return new PlatformInfo { Os = "macos", Name = "macOS", Arch = arch };
}
if (OperatingSystem.IsLinux())
{
return new PlatformInfo { Os = "linux", Name = LinuxName(osRelease), Arch = arch };
}
return new PlatformInfo { Os = "other", Name = null, Arch = arch };
}
/// <summary>从 os-release 里取发行版名字和主版本:「Debian GNU/Linux 12 (bookworm)」→「Debian 12」,「OpenWrt 24.10.0」→「OpenWrt 24.10」。</summary>
internal static string LinuxName(IEnumerable<string> osRelease)
{
var values = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (string line in osRelease)
{
int eq = line.IndexOf('=', StringComparison.Ordinal);
if (eq > 0)
{
values[line[..eq].Trim()] = line[(eq + 1)..].Trim().Trim('"', '\'');
}
}
string name = values.GetValueOrDefault("NAME") ?? values.GetValueOrDefault("ID") ?? "Linux";
name = name.Replace(" GNU/Linux", string.Empty, StringComparison.Ordinal).Trim();
string? version = values.GetValueOrDefault("VERSION_ID");
if (string.IsNullOrEmpty(version))
{
return name;
}
// 只留 主.次:Alpine 的 3.22.1、OpenWrt 的 24.10.0 都截成两段;Debian 的 12 本来就一段
string[] parts = version.Split('.');
return $"{name} {string.Join('.', parts.Take(2))}";
}
}
@@ -0,0 +1,100 @@
using System.Text;
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 节点进程和宿主机更新器之间的文件交换(容器里 <c>/etc/moshiro</c>,也就是数据目录):
/// 节点把面板发来的清单和签名写进 <c>update/</c>,把「更新」请求写成 <c>update/apply.request</c>;更新器验签并更新,状态由宿主更新器写在只读挂载的 <c>update-status/status.json</c>。
/// 节点进程不执行任何命令,也不信任清单的内容(验签在宿主机上)。没有安装更新器时目录写不了或没人读,调用只是返回 false。
/// </summary>
public sealed class UpdateBridge
{
private const int MaxManifestBytes = 4096;
private const int MaxSignatureBytes = 512;
private const int MaxStatusBytes = 4096;
private readonly string _updateDirectory;
private readonly string _statusFile;
private readonly Action<string, byte[]> _write;
private readonly INodeLog _log;
/// <param name="write">写数据目录里的文件(相对路径,原子替换):由节点传入 <c>ConfigStore.Write</c>——常驻进程里只有它可以写文件。</param>
public UpdateBridge(string dataDirectory, Action<string, byte[]> write, INodeLog log)
{
_updateDirectory = Path.Combine(dataDirectory, "update");
_statusFile = Path.Combine(dataDirectory, "update-status", "status.json");
_write = write;
_log = log;
}
/// <summary>放下面板发来的清单和签名(和已有的一样就不动,免得每次重连都惊动更新器)。先写清单、后写签名:更新器监视的是签名文件。</summary>
public bool WriteOffer(UpdateOfferParams offer)
{
byte[] manifest = Encoding.UTF8.GetBytes(offer.Manifest);
byte[] signature = Encoding.UTF8.GetBytes(offer.Signature);
if (manifest.Length is 0 or > MaxManifestBytes || signature.Length is 0 or > MaxSignatureBytes)
{
_log.Warn("ignoring an update offer of an unexpected size");
return false;
}
try
{
string manifestPath = Path.Combine(_updateDirectory, "offer.manifest");
string signaturePath = Path.Combine(_updateDirectory, "offer.sig");
if (Same(manifestPath, manifest) && Same(signaturePath, signature))
{
return true;
}
_write("update/offer.manifest", manifest);
_write("update/offer.sig", signature);
_log.Info("update offer received");
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not store the update offer (is the updater installed?)", ("err", ex.Message));
return false;
}
}
/// <summary>请求更新:写一个空的 <c>apply.request</c>(更新器验签通过、清单更新才会真的动手)。</summary>
public bool RequestApply()
{
try
{
_write("update/apply.request", []);
_log.Info("update requested");
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not request the update (is the updater installed?)", ("err", ex.Message));
return false;
}
}
/// <summary>更新器的状态;没有安装更新器、文件不存在或内容不对时返回 null。</summary>
public UpdateStatus? ReadStatus()
{
try
{
if (!File.Exists(_statusFile) || new FileInfo(_statusFile).Length > MaxStatusBytes)
{
return null;
}
return JsonSerializer.Deserialize(File.ReadAllBytes(_statusFile), MoShiroJsonContext.Default.UpdateStatus);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
return null;
}
}
private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
}
@@ -1,4 +1,4 @@
namespace Monitor.Protocol; namespace MoShiro.Protocol;
[Flags] [Flags]
public enum Scope public enum Scope
@@ -1,4 +1,4 @@
namespace Monitor.Protocol; namespace MoShiro.Protocol;
/// <summary> /// <summary>
/// Kind 目录。新增一种采集方法 = 在这里加一个静态字段并放进 <see cref="All"/>; /// Kind 目录。新增一种采集方法 = 在这里加一个静态字段并放进 <see cref="All"/>;
@@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
namespace Monitor.Protocol; namespace MoShiro.Protocol;
public enum ParamType public enum ParamType
{ {
@@ -1,7 +1,7 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Fleet.Core; using Fleet.Core;
namespace Monitor.Protocol; namespace MoShiro.Protocol;
[JsonConverter(typeof(ProbeIdJsonConverter))] [JsonConverter(typeof(ProbeIdJsonConverter))]
public readonly record struct ProbeId(string Value) public readonly record struct ProbeId(string Value)
@@ -0,0 +1,13 @@
namespace MoShiro.Protocol;
/// <summary>测量路径的地址族。旧契约缺省为 IPv4;IPv6 使用独立的线路和序列。</summary>
public static class IpVersions
{
public const string V4 = "ipv4";
public const string V6 = "ipv6";
public static bool IsValid(string? value) => value is V4 or V6;
public static string LineSeries(string probeId, string ipVersion) =>
RawChunkHeader.LinePrefix + probeId + (ipVersion == V6 ? ":ipv6" : "");
}
@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace MoShiro.Protocol;
/// <summary>当前网络环境的 IPv6 诊断。仅上报状态,不传地址、接口名称或路由内容。</summary>
public sealed class Ipv6Diagnostic
{
[JsonPropertyName("socket_supported")]
public bool SocketSupported { get; set; }
[JsonPropertyName("listening")]
public bool? Listening { get; set; }
[JsonPropertyName("network_scope")]
public string NetworkScope { get; set; } = "unknown";
[JsonPropertyName("host_has_global_address")]
public bool? HostHasGlobalAddress { get; set; }
[JsonPropertyName("host_has_default_route")]
public bool? HostHasDefaultRoute { get; set; }
[JsonPropertyName("has_global_address")]
public bool? HasGlobalAddress { get; set; }
[JsonPropertyName("has_default_route")]
public bool? HasDefaultRoute { get; set; }
[JsonPropertyName("state")]
public string State { get; set; } = "unknown";
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\fleet\src\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Rpc\Fleet.Rpc.csproj" />
</ItemGroup>
<!--
构建信息:版本来自 $(Version),tag、提交、构建时间来自环境变量 MOSHIRO_TAG / MOSHIRO_COMMIT / MOSHIRO_BUILT_AT
(scripts/publish.sh 和 CI 会设置;本地开发不设置就是空,这样本地构建是可重复的、不会每次都重新编译)。
生成一个带常量的源文件,NativeAOT 下不用反射。
-->
<Target Name="WriteBuildInfo" BeforeTargets="CoreCompile">
<PropertyGroup>
<BuildInfoFile>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoFile>
</PropertyGroup>
<ItemGroup>
<BuildInfoLine Include="namespace MoShiro.Protocol%3B" />
<BuildInfoLine Include="/// &lt;summary&gt;这个程序是什么时候、从哪个版本构建出来的(构建时生成)。&lt;/summary&gt;" />
<BuildInfoLine Include="public static class BuildInfo" />
<BuildInfoLine Include="{" />
<BuildInfoLine Include=" public const string Version = &quot;$(Version)&quot;%3B" />
<BuildInfoLine Include=" public const string Tag = &quot;$(MOSHIRO_TAG)&quot;%3B" />
<BuildInfoLine Include=" public const string Commit = &quot;$(MOSHIRO_COMMIT)&quot;%3B" />
<BuildInfoLine Include=" public const string BuiltAt = &quot;$(MOSHIRO_BUILT_AT)&quot;%3B" />
<BuildInfoLine Include="}" />
</ItemGroup>
<WriteLinesToFile File="$(BuildInfoFile)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
<ItemGroup>
<Compile Include="$(BuildInfoFile)" />
<FileWrites Include="$(BuildInfoFile)" />
</ItemGroup>
</Target>
</Project>
@@ -1,9 +1,9 @@
using Fleet.Core; using Fleet.Core;
namespace Monitor.Protocol; namespace MoShiro.Protocol;
/// <summary>Monitor 的错误码(在 Fleet 错误码之上扩展)。REST、JSON-RPC、OpenAPI 共用同一份。</summary> /// <summary>MoShiro 的错误码(在 Fleet 错误码之上扩展)。REST、JSON-RPC、OpenAPI 共用同一份。</summary>
public static class MonitorErrors public static class MoShiroErrors
{ {
public static readonly ErrorCode ValidationFailed = new("validation_failed"); public static readonly ErrorCode ValidationFailed = new("validation_failed");
public static readonly ErrorCode BudgetExceeded = new("budget_exceeded"); public static readonly ErrorCode BudgetExceeded = new("budget_exceeded");
@@ -1,4 +1,4 @@
namespace Monitor.Protocol; namespace MoShiro.Protocol;
public static class ProtocolVersion public static class ProtocolVersion
{ {
@@ -25,8 +25,8 @@ public static class ProtocolLimits
public const long MaxLineBudgetBitsPerSecond = 100_000; public const long MaxLineBudgetBitsPerSecond = 100_000;
} }
/// <summary>二进制通道号(0–15 属于 Fleet,Monitor 从 16 开始)。</summary> /// <summary>二进制通道号(0–15 属于 Fleet,MoShiro 从 16 开始)。</summary>
public static class MonitorChannels public static class MoShiroChannels
{ {
/// <summary>Agent → Backend:RawChunk(发件箱)。</summary> /// <summary>Agent → Backend:RawChunk(发件箱)。</summary>
public const byte RawChunk = 16; public const byte RawChunk = 16;
@@ -35,9 +35,9 @@ public static class MonitorChannels
public const byte ColumnBlock = 17; public const byte ColumnBlock = 17;
} }
public static class MonitorCapabilities public static class MoShiroCapabilities
{ {
public const string Base = "monitor.v1"; public const string Base = "moshiro.v1";
public const string KindsPrefix = "monitor.kinds:"; public const string KindsPrefix = "moshiro.kinds:";
public const string BlocksPrefix = "monitor.blocks:"; public const string BlocksPrefix = "moshiro.blocks:";
} }
@@ -1,4 +1,4 @@
namespace Monitor.Protocol; namespace MoShiro.Protocol;
/// <summary>CRC-32C(Castagnoli),RawChunk 的完整性校验。</summary> /// <summary>CRC-32C(Castagnoli),RawChunk 的完整性校验。</summary>
public static class Crc32c public static class Crc32c
@@ -2,7 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Fleet.Core; using Fleet.Core;
namespace Monitor.Protocol; namespace MoShiro.Protocol;
/// <summary>Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。</summary> /// <summary>Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。</summary>
public sealed class ChunkClock public sealed class ChunkClock
@@ -58,6 +58,10 @@ public sealed class StreamHeader
/// <summary>块头(JSON,自描述):列定义写在块里,新增列或新增 Kind 不需要升级 container_version。</summary> /// <summary>块头(JSON,自描述):列定义写在块里,新增列或新增 Kind 不需要升级 container_version。</summary>
public sealed class RawChunkHeader public sealed class RawChunkHeader
{ {
[JsonPropertyName("ip_version")]
[JsonPropertyOrder(1)]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonPropertyName("chunk_id")] [JsonPropertyName("chunk_id")]
public ChunkId ChunkId { get; set; } public ChunkId ChunkId { get; set; }
@@ -113,10 +117,10 @@ public sealed class RawChunkHeader
/// <summary> /// <summary>
/// "line:&lt;probe_id&gt;" 或 "target:&lt;task_id&gt;":存储和分析用的序列标识。 /// "line:&lt;probe_id&gt;" 或 "target:&lt;task_id&gt;":存储和分析用的序列标识。
/// 一条线路是一对(Probe,Agent):同一个 Agent 上每个 Probe 的数据各是一个序列,互不混合。 /// 同一个 Agent 上每个 Probe 的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。
/// </summary> /// </summary>
[JsonIgnore] [JsonIgnore]
public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : LinePrefix + (ProbeId?.Value ?? ""); public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : IpVersions.LineSeries(ProbeId?.Value ?? "", IpVersion);
public const string LinePrefix = "line:"; public const string LinePrefix = "line:";
} }
@@ -3,18 +3,18 @@ using System.IO.Compression;
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
namespace Monitor.Protocol; namespace MoShiro.Protocol;
/// <summary> /// <summary>
/// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。 /// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。
/// 布局:<c>"MNRC" | u8 container_version | u32 crc32c(压缩数据) | Brotli( u32 header_len | header JSON | 各列数据 )</c>。 /// 布局:<c>"MSRC" | u8 container_version | u32 crc32c(压缩数据) | Brotli( u32 header_len | header JSON | 各列数据 )</c>。
/// 列数据:可空列先写有效位图,再写有效值;plain 为无符号 varint(有符号类型用 zigzag),delta 为与上一个有效值之差的 zigzag varint。 /// 列数据:可空列先写有效位图,再写有效值;plain 为无符号 varint(有符号类型用 zigzag),delta 为与上一个有效值之差的 zigzag varint。
/// </summary> /// </summary>
public static class RawChunkCodec public static class RawChunkCodec
{ {
private const int PrefixBytes = 4 + 1 + 4; private const int PrefixBytes = 4 + 1 + 4;
private static ReadOnlySpan<byte> Magic => "MNRC"u8; private static ReadOnlySpan<byte> Magic => "MSRC"u8;
/// <summary>压缩之前的正文:<c>u32 header_len | header JSON | 各列数据</c>。布局变化时黄金字节测试会失败,提醒升级版本。</summary> /// <summary>压缩之前的正文:<c>u32 header_len | header JSON | 各列数据</c>。布局变化时黄金字节测试会失败,提醒升级版本。</summary>
public static byte[] EncodeBody(RawChunk chunk) public static byte[] EncodeBody(RawChunk chunk)
@@ -40,7 +40,7 @@ public static class RawChunkCodec
header.Streams.Add(streamHeader); header.Streams.Add(streamHeader);
} }
byte[] headerJson = JsonSerializer.SerializeToUtf8Bytes(header, MonitorJsonContext.Default.RawChunkHeader); byte[] headerJson = JsonSerializer.SerializeToUtf8Bytes(header, MoShiroJsonContext.Default.RawChunkHeader);
using var body = new MemoryStream(); using var body = new MemoryStream();
Span<byte> length = stackalloc byte[4]; Span<byte> length = stackalloc byte[4];
@@ -114,7 +114,7 @@ public static class RawChunkCodec
RawChunkHeader? header; RawChunkHeader? header;
try try
{ {
header = JsonSerializer.Deserialize(plain.AsSpan(4, (int)headerLength), MonitorJsonContext.Default.RawChunkHeader); header = JsonSerializer.Deserialize(plain.AsSpan(4, (int)headerLength), MoShiroJsonContext.Default.RawChunkHeader);
} }
catch (JsonException) catch (JsonException)
{ {
@@ -159,7 +159,7 @@ public static class RawChunkCodec
} }
private static Result<RawChunk> Corrupt(string message) => private static Result<RawChunk> Corrupt(string message) =>
Result<RawChunk>.Fail(MonitorErrors.RawCorrupt, message); Result<RawChunk>.Fail(MoShiroErrors.RawCorrupt, message);
private static byte[]? Inflate(ReadOnlySpan<byte> payload) private static byte[]? Inflate(ReadOnlySpan<byte> payload)
{ {
@@ -1,4 +1,4 @@
namespace Monitor.Protocol; namespace MoShiro.Protocol;
/// <summary>按 <see cref="StreamSpec"/> 逐行构造一个 <see cref="RawStream"/>(用于测试、模拟器和离线工具,不用于节点热路径)。</summary> /// <summary>按 <see cref="StreamSpec"/> 逐行构造一个 <see cref="RawStream"/>(用于测试、模拟器和离线工具,不用于节点热路径)。</summary>
public sealed class StreamBuilder public sealed class StreamBuilder
@@ -2,25 +2,25 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Fleet.Core; using Fleet.Core;
namespace Monitor.Protocol; namespace MoShiro.Protocol;
/// <summary>monitor.* 方法名。新增方法 = 在这里定义名称和 params/result,再在组装处加一行 <c>.On(...)</c>。</summary> /// <summary>moshiro.* 方法名。新增方法 = 在这里定义名称和 params/result,再在组装处加一行 <c>.On(...)</c>。</summary>
public static class MonitorMethods public static class MoShiroMethods
{ {
public const string TestRun = "monitor.test.run"; public const string TestRun = "moshiro.test.run";
public const string TestCancel = "monitor.test.cancel"; public const string TestCancel = "moshiro.test.cancel";
public const string ProbeDisconnect = "monitor.probe.disconnect"; public const string ProbeDisconnect = "moshiro.probe.disconnect";
public const string TestStatus = "monitor.test.status"; public const string TestStatus = "moshiro.test.status";
public const string Event = "monitor.event"; public const string Event = "moshiro.event";
public const string Host = "monitor.host"; public const string Host = "moshiro.host";
public const string ProbeAgents = "monitor.probe.agents"; public const string UpdateOffer = "moshiro.update.offer";
public const string ProbeAgentsChanged = "monitor.probe.agents_changed"; public const string UpdateApply = "moshiro.update.apply";
// Probe ↔ Agent(点对点会话) // Probe ↔ Agent(点对点会话)
public const string Clock = "monitor.clock"; public const string Clock = "moshiro.clock";
public const string TestPhase = "monitor.test.phase"; public const string TestPhase = "moshiro.test.phase";
public const string TestPhaseEnd = "monitor.test.phase_end"; public const string TestPhaseEnd = "moshiro.test.phase_end";
public const string TestAbort = "monitor.test.abort"; public const string TestAbort = "moshiro.test.abort";
} }
public static class AgentEventKinds public static class AgentEventKinds
@@ -61,6 +61,9 @@ public sealed class TestPhase
public sealed class TestRunParams public sealed class TestRunParams
{ {
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonPropertyName("test_id")] [JsonPropertyName("test_id")]
public TestId TestId { get; set; } public TestId TestId { get; set; }
@@ -127,6 +130,9 @@ public sealed class AgentEventParams
public sealed class AgentEndpoint public sealed class AgentEndpoint
{ {
[JsonPropertyName("ipv6_host")]
public string? Ipv6Host { get; set; }
[JsonPropertyName("agent_id")] [JsonPropertyName("agent_id")]
public NodeId AgentId { get; set; } public NodeId AgentId { get; set; }
@@ -147,15 +153,11 @@ public sealed class AgentEndpoint
public string Spki { get; set; } = ""; public string Spki { get; set; } = "";
} }
public sealed class ProbeAgentsParams /// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
public sealed class ProbeAgentList
{ {
/// <summary>Probe 目前持有的列表版本(用于调试;服务端总是返回当前列表)。</summary> public const string StateType = "probe_agents";
[JsonPropertyName("have_version")]
public long HaveVersion { get; set; }
}
public sealed class ProbeAgentsResult
{
[JsonPropertyName("version")] [JsonPropertyName("version")]
public long Version { get; set; } public long Version { get; set; }
@@ -163,7 +165,7 @@ public sealed class ProbeAgentsResult
public List<AgentEndpoint> Agents { get; set; } = []; public List<AgentEndpoint> Agents { get; set; } = [];
} }
/// <summary>注册元数据(Monitor 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary> /// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
public sealed class AgentEnrollMeta public sealed class AgentEnrollMeta
{ {
[JsonPropertyName("probe_tcp_port")] [JsonPropertyName("probe_tcp_port")]
@@ -194,9 +196,121 @@ public sealed class LocalLimits
public int MaxConcurrentTests { get; set; } = 1; public int MaxConcurrentTests { get; set; } = 1;
} }
/// <summary>
/// 节点的系统和架构(粗粒度,只用于展示):<c>os</c> 是 linux / windows / macos / other,<c>name</c> 是「Debian 12」「Windows 11」这样的名字,
/// <c>arch</c> 是 x64 / arm64 / arm / x86。<b>没有主机名、内核版本、具体型号</b>。另外带着这个程序自己的构建信息(版本、tag、构建时间)。
/// </summary>
public sealed class PlatformInfo
{
[JsonPropertyName("os")]
public string Os { get; set; } = "other";
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("arch")]
public string? Arch { get; set; }
/// <summary>程序的版本、tag、提交(短)和构建时间(UTC,ISO 8601):<see cref="BuildInfo"/> 构建时写进去的,没设置就是空。</summary>
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("tag")]
public string? Tag { get; set; }
[JsonPropertyName("commit")]
public string? Commit { get; set; }
[JsonPropertyName("built_at")]
public string? BuiltAt { get; set; }
}
/// <summary>
/// 面板给节点的更新清单(<c>moshiro.update.offer</c>):<c>manifest</c> 是发布清单的原始文本,<c>signature</c> 是 base64 的签名。
/// 节点只是把它们原样放进数据目录;验签、拉取、重启都由宿主机上的更新器完成(节点进程不执行任何命令,也不信任清单的内容)。
/// </summary>
public sealed class UpdateOfferParams
{
[JsonPropertyName("manifest")]
public string Manifest { get; set; } = "";
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
}
/// <summary>宿主机更新器已经验证过的可用更新。</summary>
public sealed class UpdateAvailable
{
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("tag")]
public string? Tag { get; set; }
[JsonPropertyName("digest")]
public string? Digest { get; set; }
[JsonPropertyName("seq")]
public long Seq { get; set; }
}
/// <summary>
/// 宿主机更新器的状态(它写在 <c>update-status/status.json</c>,节点读出来随心跳上报):
/// <c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>(清单验签或校验没过)。
/// 没有安装更新器的节点没有这个字段。
/// </summary>
public sealed class UpdateStatus
{
[JsonPropertyName("state")]
public string State { get; set; } = "";
/// <summary>正在运行的镜像 digest(更新器记录的;还没更新过时为空)。</summary>
[JsonPropertyName("current")]
public string? Current { get; set; }
[JsonPropertyName("available")]
public UpdateAvailable? Available { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
[JsonPropertyName("at")]
public long At { get; set; }
}
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
public sealed class ProbeHeartbeatExtra
{
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
[JsonPropertyName("update")]
public UpdateStatus? Update { get; set; }
}
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
public sealed class ProbeHelloMeta
{
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
[JsonPropertyName("platform")]
public PlatformInfo? Platform { get; set; }
}
/// <summary><c>fleet.hello</c> 的附加字段。</summary> /// <summary><c>fleet.hello</c> 的附加字段。</summary>
public sealed class AgentHelloMeta public sealed class AgentHelloMeta
{ {
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
/// <summary>本机是否启用了 IPv6 监听。旧 Agent 缺省为 false,面板不会给它安排 IPv6 会话。</summary>
[JsonPropertyName("ipv6_supported")]
public bool Ipv6Supported { get; set; }
[JsonPropertyName("platform")]
public PlatformInfo? Platform { get; set; }
[JsonPropertyName("tls_spki")] [JsonPropertyName("tls_spki")]
public string? TlsSpki { get; set; } public string? TlsSpki { get; set; }
@@ -244,6 +358,10 @@ public sealed class HostSnapshot
[JsonPropertyName("net_tx_bps")] [JsonPropertyName("net_tx_bps")]
public long? NetTxBps { get; set; } public long? NetTxBps { get; set; }
/// <summary>已统计接口的内核报告链路速率之和(Mbit/s);驱动不支持或无法读取时为空,不代表互联网带宽。</summary>
[JsonPropertyName("net_link_mbps")]
public long? NetLinkMbps { get; set; }
[JsonPropertyName("uptime_s")] [JsonPropertyName("uptime_s")]
public long? UptimeSeconds { get; set; } public long? UptimeSeconds { get; set; }
@@ -254,6 +372,9 @@ public sealed class HostSnapshot
/// <summary>Agent 眼中的一个已连接 Probe。</summary> /// <summary>Agent 眼中的一个已连接 Probe。</summary>
public sealed class ConnectedProbe public sealed class ConnectedProbe
{ {
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonPropertyName("id")] [JsonPropertyName("id")]
public ProbeId Id { get; set; } public ProbeId Id { get; set; }
@@ -271,6 +392,9 @@ public sealed class ConnectedProbe
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary> /// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
public sealed class AgentHeartbeatExtra public sealed class AgentHeartbeatExtra
{ {
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
/// <summary>当前连着的 Probe 会话数(等于 <see cref="Probes"/> 的个数)。</summary> /// <summary>当前连着的 Probe 会话数(等于 <see cref="Probes"/> 的个数)。</summary>
[JsonPropertyName("sessions")] [JsonPropertyName("sessions")]
public int Sessions { get; set; } public int Sessions { get; set; }
@@ -293,6 +417,9 @@ public sealed class AgentHeartbeatExtra
[JsonPropertyName("queue_bytes")] [JsonPropertyName("queue_bytes")]
public long QueueBytes { get; set; } public long QueueBytes { get; set; }
[JsonPropertyName("update")]
public UpdateStatus? Update { get; set; }
} }
/// <summary>Agent 下发给 Probe 的一条线路任务(期望状态类型 <c>line_task_set</c> 里的一项)。</summary> /// <summary>Agent 下发给 Probe 的一条线路任务(期望状态类型 <c>line_task_set</c> 里的一项)。</summary>
@@ -334,7 +461,7 @@ public sealed class LineTaskSet
public List<int> TcpPorts { get; set; } = []; public List<int> TcpPorts { get; set; } = [];
} }
/// <summary><c>monitor.clock</c>:Probe 用它估计与 Agent 单调时钟的偏移(对称往返假设)。</summary> /// <summary><c>moshiro.clock</c>:Probe 用它估计与 Agent 单调时钟的偏移(对称往返假设)。</summary>
public sealed class ClockParams public sealed class ClockParams
{ {
[JsonPropertyName("probe_mono_us")] [JsonPropertyName("probe_mono_us")]
@@ -431,10 +558,15 @@ public static class FragmentKeys
[JsonSerializable(typeof(HostSnapshot))] [JsonSerializable(typeof(HostSnapshot))]
[JsonSerializable(typeof(TestStatusParams))] [JsonSerializable(typeof(TestStatusParams))]
[JsonSerializable(typeof(AgentEventParams))] [JsonSerializable(typeof(AgentEventParams))]
[JsonSerializable(typeof(ProbeAgentsParams))] [JsonSerializable(typeof(ProbeAgentList))]
[JsonSerializable(typeof(ProbeAgentsResult))] [JsonSerializable(typeof(UpdateOfferParams))]
[JsonSerializable(typeof(UpdateStatus))]
[JsonSerializable(typeof(ProbeHeartbeatExtra))]
[JsonSerializable(typeof(ProbeHelloMeta))]
[JsonSerializable(typeof(AgentEnrollMeta))] [JsonSerializable(typeof(AgentEnrollMeta))]
[JsonSerializable(typeof(PlatformInfo))]
[JsonSerializable(typeof(AgentHelloMeta))] [JsonSerializable(typeof(AgentHelloMeta))]
[JsonSerializable(typeof(Ipv6Diagnostic))]
[JsonSerializable(typeof(AgentHeartbeatExtra))] [JsonSerializable(typeof(AgentHeartbeatExtra))]
[JsonSerializable(typeof(RawChunkHeader))] [JsonSerializable(typeof(RawChunkHeader))]
public sealed partial class MonitorJsonContext : JsonSerializerContext; public sealed partial class MoShiroJsonContext : JsonSerializerContext;
@@ -3,7 +3,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Fleet.Core; using Fleet.Core;
namespace Monitor.Protocol; namespace MoShiro.Protocol;
/// <summary>Task = { id, scope, kind, target, params, schedule, enabled }。参数已经按 ParamSpec 校验并补全默认值。</summary> /// <summary>Task = { id, scope, kind, target, params, schedule, enabled }。参数已经按 ParamSpec 校验并补全默认值。</summary>
public sealed class TaskSpec public sealed class TaskSpec
@@ -56,9 +56,6 @@ public sealed class TaskSet
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary> /// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
[JsonPropertyName("peers")] [JsonPropertyName("peers")]
public List<PeerSpec> Peers { get; set; } = []; public List<PeerSpec> Peers { get; set; } = [];
[JsonPropertyName("probe_agents_version")]
public long ProbeAgentsVersion { get; set; }
} }
public static class TaskSchedule public static class TaskSchedule
@@ -82,9 +79,9 @@ public static class TaskSchedule
{ {
return minutes >= 60 return minutes >= 60
? Result<int?>.Ok(minutes) ? Result<int?>.Ok(minutes)
: Result<int?>.Fail(MonitorErrors.ValidationFailed, "every:N requires N >= 60 minutes"); : Result<int?>.Fail(MoShiroErrors.ValidationFailed, "every:N requires N >= 60 minutes");
} }
return Result<int?>.Fail(MonitorErrors.ValidationFailed, "schedule must be continuous, once or every:<minutes>"); return Result<int?>.Fail(MoShiroErrors.ValidationFailed, "schedule must be continuous, once or every:<minutes>");
} }
} }
@@ -2,7 +2,7 @@ using System.Buffers.Binary;
using System.Security.Cryptography; using System.Security.Cryptography;
using Fleet.Core; using Fleet.Core;
namespace Monitor.Protocol; namespace MoShiro.Protocol;
/// <summary> /// <summary>
/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 Probe 发起;TLS 是可选的(ClientHello 以 0x16 开头, /// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 Probe 发起;TLS 是可选的(ClientHello 以 0x16 开头,
@@ -1,7 +1,7 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Security.Cryptography; using System.Security.Cryptography;
namespace Monitor.Protocol; namespace MoShiro.Protocol;
public enum UdpPacketType : byte public enum UdpPacketType : byte
{ {
@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\fleet\src\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Rpc\Fleet.Rpc.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,31 @@
using System.Buffers.Binary;
namespace MoShiro.Measurement.Tests;
public sealed class IcmpReplyTests
{
[Theory]
[InlineData((byte)0)]
[InlineData((byte)129)]
public void ReplyMustMatchTypeCodeSequenceAndPayload(byte replyType)
{
byte[] request = new byte[44];
request[8] = 71;
byte[] reply = (byte[])request.Clone();
reply[0] = replyType;
BinaryPrimitives.WriteUInt16BigEndian(reply.AsSpan(6), 123);
// Linux changes the identifier; it is not the request's zero identifier.
reply[4] = 42;
Assert.True(LinuxIcmpEcho.IsReply(reply, request, replyType, 123));
Assert.False(LinuxIcmpEcho.IsReply(reply, request, replyType, 124));
Assert.False(LinuxIcmpEcho.IsReply(reply.AsSpan(0, 7), request, replyType, 123));
reply[1] = 1;
Assert.False(LinuxIcmpEcho.IsReply(reply, request, replyType, 123));
reply[1] = 0;
reply[0]++;
Assert.False(LinuxIcmpEcho.IsReply(reply, request, replyType, 123));
reply[0] = replyType;
reply[8]++;
Assert.False(LinuxIcmpEcho.IsReply(reply, request, replyType, 123));
}
}
@@ -0,0 +1,50 @@
using MoShiro.Measurement;
namespace MoShiro.Measurement.Tests;
public sealed class Ipv6DiagnosticsTests
{
[Fact]
public void Addresses_IgnoresLocalTentativeFailedAndDeprecatedAddresses()
{
Assert.Null(Ipv6Diagnostics.ParseAddresses(null));
Assert.False(Ipv6Diagnostics.ParseAddresses(""));
Assert.False(Ipv6Diagnostics.ParseAddresses("00000000000000000000000000000001 01 80 10 80 lo\nfe800000000000000000000000000001 02 40 20 80 eth0"));
foreach (string flags in new[] { "40", "08", "20" })
{
Assert.False(Ipv6Diagnostics.ParseAddresses($"20010db8000000000000000000000001 02 40 00 {flags} eth0"));
}
Assert.True(Ipv6Diagnostics.ParseAddresses("20010db8000000000000000000000001 02 40 00 80 eth0"));
}
[Fact]
public void Routes_RequiresAnActiveNonLoopbackDefaultRoute()
{
Assert.Null(Ipv6Diagnostics.ParseRoutes(null));
Assert.False(Ipv6Diagnostics.ParseRoutes(""));
string prefix = "00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000000 00000000 ";
Assert.True(Ipv6Diagnostics.ParseRoutes(prefix + "00000003 eth0"));
Assert.False(Ipv6Diagnostics.ParseRoutes(prefix + "00000201 eth0"));
Assert.False(Ipv6Diagnostics.ParseRoutes(prefix + "00000001 lo"));
Assert.False(Ipv6Diagnostics.ParseRoutes(prefix + "00000000 eth0"));
}
[Theory]
[InlineData(false, true, true, true, "unsupported")]
[InlineData(true, true, false, true, "no_global_address")]
[InlineData(true, false, true, true, "not_listening")]
[InlineData(true, true, true, false, "no_default_route")]
[InlineData(true, true, true, true, "ready")]
public void Evaluate_DistinguishesConfigurationFromAddressAndRouting(bool supported, bool listening, bool address, bool route, string expected)
{
Assert.Equal(expected, Ipv6Diagnostics.Evaluate(supported, listening, address, route).State);
}
[Fact]
public void Probe_DoesNotRequireAnInboundListener_AndUnavailableDataStaysUnknown()
{
Assert.Equal("ready", Ipv6Diagnostics.Evaluate(true, null, true, true).State);
Assert.Equal("unknown", Ipv6Diagnostics.Evaluate(true, null, null, null).State);
}
}
@@ -1,8 +1,8 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Measurement.Tests; namespace MoShiro.Measurement.Tests;
public sealed class MeasurementTests public sealed class MeasurementTests
{ {
@@ -5,7 +5,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\Monitor.Measurement\Monitor.Measurement.csproj" /> <ProjectReference Include="..\..\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
@@ -0,0 +1,27 @@
namespace MoShiro.Measurement.Tests;
public sealed class PlatformDetectorTests
{
[Theory]
[InlineData("PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"|NAME=\"Debian GNU/Linux\"|VERSION_ID=\"12\"|ID=debian", "Debian 12")]
[InlineData("NAME=\"OpenWrt\"|VERSION_ID=\"24.10.0\"|ID=openwrt", "OpenWrt 24.10")]
[InlineData("NAME=\"Alpine Linux\"|ID=alpine|VERSION_ID=3.22.1", "Alpine Linux 3.22")]
[InlineData("NAME=\"Ubuntu\"|VERSION_ID=\"24.04\"", "Ubuntu 24.04")]
[InlineData("ID=arch", "arch")]
[InlineData("", "Linux")]
public void LinuxName_IsTheDistroAndItsMajorMinorVersion(string osRelease, string expected)
{
Assert.Equal(expected, PlatformDetector.LinuxName(osRelease.Split('|', StringSplitOptions.RemoveEmptyEntries)));
}
[Fact]
public void Detect_ReportsOnlyCoarseInformation()
{
var platform = PlatformDetector.Detect();
Assert.Contains(platform.Os, new[] { "linux", "windows", "macos", "other" });
Assert.Contains(platform.Arch, new[] { "x64", "arm64", "arm", "x86" });
// 没有主机名、内核版本:名字很短,也不含小数点以外的版本细节
Assert.True((platform.Name ?? string.Empty).Length < 40);
Assert.DoesNotContain(Environment.MachineName, platform.Name ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
}
@@ -1,8 +1,8 @@
using System.Text.Json; using System.Text.Json;
using Fleet.Core; using Fleet.Core;
using Monitor.Protocol; using MoShiro.Protocol;
namespace Monitor.Protocol.Tests; namespace MoShiro.Protocol.Tests;
public sealed class CatalogTests public sealed class CatalogTests
{ {
@@ -101,8 +101,8 @@ public sealed class CatalogTests
Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }], Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }],
Peers = [new PeerSpec { ProbeId = new ProbeId("prb_1"), PublicKey = "AAAA" }], Peers = [new PeerSpec { ProbeId = new ProbeId("prb_1"), PublicKey = "AAAA" }],
}; };
string text = JsonSerializer.Serialize(set, MonitorJsonContext.Default.TaskSet); string text = JsonSerializer.Serialize(set, MoShiroJsonContext.Default.TaskSet);
TaskSet back = JsonSerializer.Deserialize(text, MonitorJsonContext.Default.TaskSet)!; TaskSet back = JsonSerializer.Deserialize(text, MoShiroJsonContext.Default.TaskSet)!;
Assert.Contains("\"id\":\"tsk_1\"", text); Assert.Contains("\"id\":\"tsk_1\"", text);
Assert.Equal(7, back.Version); Assert.Equal(7, back.Version);
@@ -114,7 +114,7 @@ public sealed class CatalogTests
[Fact] [Fact]
public void AgentEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults() public void AgentEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
{ {
AgentEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MonitorJsonContext.Default.AgentEnrollMeta)!; AgentEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.AgentEnrollMeta)!;
Assert.Equal(7443, meta.ProbeTcpPort); Assert.Equal(7443, meta.ProbeTcpPort);
Assert.Equal(["udp_echo"], meta.Kinds); Assert.Equal(["udp_echo"], meta.Kinds);
} }
@@ -122,7 +122,7 @@ public sealed class CatalogTests
[Fact] [Fact]
public void ErrorCodes_AreSnakeCase() public void ErrorCodes_AreSnakeCase()
{ {
foreach (System.Reflection.FieldInfo field in typeof(MonitorErrors).GetFields()) foreach (System.Reflection.FieldInfo field in typeof(MoShiroErrors).GetFields())
{ {
string value = ((ErrorCode)field.GetValue(null)!).Value; string value = ((ErrorCode)field.GetValue(null)!).Value;
Assert.Matches("^[a-z]+(_[a-z]+)*$", value); Assert.Matches("^[a-z]+(_[a-z]+)*$", value);
@@ -5,8 +5,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\Monitor.Protocol\Monitor.Protocol.csproj" /> <ProjectReference Include="..\..\MoShiro.Protocol\MoShiro.Protocol.csproj" />
<ProjectReference Include="..\..\..\tests\Monitor.TestSupport\Monitor.TestSupport.csproj" /> <ProjectReference Include="..\..\..\tests\MoShiro.TestSupport\MoShiro.TestSupport.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
@@ -1,13 +1,28 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.IO.Compression; using System.IO.Compression;
using Fleet.Core; using Fleet.Core;
using Monitor.Protocol; using MoShiro.Protocol;
using Monitor.TestSupport; using MoShiro.TestSupport;
namespace Monitor.Protocol.Tests; namespace MoShiro.Protocol.Tests;
public sealed class RawChunkCodecTests public sealed class RawChunkCodecTests
{ {
[Fact]
public void Header_LegacyDefaultsToIpv4_AndIpv6RoundTripsToAnIndependentSeries()
{
RawChunkHeader legacy = System.Text.Json.JsonSerializer.Deserialize("{\"probe_id\":\"prb_legacy\"}", MoShiroJsonContext.Default.RawChunkHeader)!;
Assert.Equal(IpVersions.V4, legacy.IpVersion);
Assert.Equal("line:prb_legacy", legacy.Series);
RawChunk chunk = SampleChunk();
chunk.Header.ProbeId = new ProbeId("prb_dual");
chunk.Header.IpVersion = IpVersions.V6;
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(chunk)).Value;
Assert.Equal("ipv6", decoded.Header.IpVersion);
Assert.Equal("line:prb_dual:ipv6", decoded.Header.Series);
Assert.Equal(chunk.Find("msg")!.Count, decoded.Find("msg")!.Count);
}
private static readonly NodeId s_agent = new("agt_test"); private static readonly NodeId s_agent = new("agt_test");
private static RawChunk SampleChunk() private static RawChunk SampleChunk()
@@ -103,7 +118,7 @@ public sealed class RawChunkCodecTests
byte[] badCrc = (byte[])good.Clone(); byte[] badCrc = (byte[])good.Clone();
badCrc[^1] ^= 0xFF; badCrc[^1] ^= 0xFF;
Assert.Equal(MonitorErrors.RawCorrupt, RawChunkCodec.Decode(badMagic).Error.Code); Assert.Equal(MoShiroErrors.RawCorrupt, RawChunkCodec.Decode(badMagic).Error.Code);
Assert.Contains("version", RawChunkCodec.Decode(badVersion).Error.Message); Assert.Contains("version", RawChunkCodec.Decode(badVersion).Error.Message);
Assert.Contains("crc", RawChunkCodec.Decode(badCrc).Error.Message); Assert.Contains("crc", RawChunkCodec.Decode(badCrc).Error.Message);
Assert.False(RawChunkCodec.Decode(good.AsSpan(0, good.Length / 2)).IsSuccess); Assert.False(RawChunkCodec.Decode(good.AsSpan(0, good.Length / 2)).IsSuccess);
@@ -190,7 +205,7 @@ public sealed class RawChunkCodecTests
private static byte[] Wrap(byte[] payload) private static byte[] Wrap(byte[] payload)
{ {
byte[] result = new byte[9 + payload.Length]; byte[] result = new byte[9 + payload.Length];
"MNRC"u8.CopyTo(result); "MSRC"u8.CopyTo(result);
result[4] = ProtocolVersion.Container; result[4] = ProtocolVersion.Container;
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(5), Crc32c.Compute(payload)); BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(5), Crc32c.Compute(payload));
payload.CopyTo(result, 9); payload.CopyTo(result, 9);
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff. Show more