48 Commits
Author SHA1 Message Date
justme 3364f7903e fix: bind Backend host service to its configured port (#17) [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
Keep the Backend host-network container reachable on its configured port.
2026-10-07 05:11:52 +00:00
justme 4e1b372e66 fix: bind Backend host service to its configured port [CI]
ci/woodpecker/pr/verify Pipeline was successful
ci/woodpecker/pr/web-build Pipeline was successful
2026-10-07 13:08:08 +08:00
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
380 changed files with 14497 additions and 2948 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
+1
View File
@@ -1,4 +1,5 @@
artifacts/
.release/
bin/
obj/
*.user
+43
View File
@@ -0,0 +1,43 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
when:
- event: tag
ref: refs/tags/v*
depends_on:
- linux-release
- windows-release
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
image: bash
environment:
GITEA_BASE: http://127.0.0.1:3100
GITEA_TOKEN:
from_secret: gitea_token
commands:
- bash ci/publish-gitea-release.sh
depends_on:
- publish-agent-probe-oci
+59
View File
@@ -0,0 +1,59 @@
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
when:
- event: tag
ref: refs/tags/v*
concurrency: 1
steps:
- name: 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
commands:
- bash ci/release-linux.sh amd64 backend
depends_on: []
- name: linux-arm64-backend
image: bash
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
ZOT_REGISTRY: registry.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
commands:
- bash ci/release-linux.sh arm64 backend
depends_on: []
- name: backend-multiarch-index
image: bash
environment:
ZOT_REGISTRY: registry.oeo.one
ZOT_SCHEME: https
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/index-backend-image.sh
depends_on:
- linux-amd64-backend
- linux-arm64-backend
+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: []
+26
View File
@@ -0,0 +1,26 @@
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
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
steps:
- name: backend-tests
image: bash
# [SKIP TESTS] omits Backend tests only; web-build remains a dependent workflow.
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
environment:
NUGET_PACKAGES: /home/justme/.nuget/packages
commands:
- cd backend && dotnet test --solution MoShiro.slnx -c Release
+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
+246
View File
@@ -0,0 +1,246 @@
labels:
ci: domain-windows
platform: windows/amd64
clone:
- name: git
image: plugin-git
settings:
lfs: false
when:
- event: tag
ref: refs/tags/v*
concurrency: 1
steps:
- name: build-linux-x64-agent
image: powershell
environment: &build-environment
GITEA_BASE: http://localhost:3100
GITEA_TOKEN:
from_secret: gitea_token
NUGET_PACKAGES: 'C:\Users\JustMe-NAS\.nuget\packages'
RID: linux-x64
COMPONENT: agent
commands: &build-commands
- Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
- ./ci/release-cross-platform.ps1 -RuntimeIdentifier $env:RID -Component $env:COMPONENT
depends_on: []
- name: build-linux-x64-probe
image: powershell
environment: &rid-linux-x64-probe
<<: *build-environment
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-linux-x64-enroll
image: powershell
environment: &rid-linux-x64-enroll
<<: *build-environment
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-linux-arm64-agent
image: powershell
environment: &rid-linux-arm64-agent
<<: *build-environment
RID: linux-arm64
commands: *build-commands
depends_on: []
- name: build-linux-arm64-probe
image: powershell
environment: &rid-linux-arm64-probe
<<: *rid-linux-arm64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-linux-arm64-enroll
image: powershell
environment: &rid-linux-arm64-enroll
<<: *rid-linux-arm64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-linux-musl-x64-agent
image: powershell
environment: &rid-linux-musl-x64-agent
<<: *build-environment
RID: linux-musl-x64
commands: *build-commands
depends_on: []
- name: build-linux-musl-x64-probe
image: powershell
environment: &rid-linux-musl-x64-probe
<<: *rid-linux-musl-x64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-linux-musl-x64-enroll
image: powershell
environment: &rid-linux-musl-x64-enroll
<<: *rid-linux-musl-x64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-linux-musl-arm64-agent
image: powershell
environment: &rid-linux-musl-arm64-agent
<<: *build-environment
RID: linux-musl-arm64
commands: *build-commands
depends_on: []
- name: build-linux-musl-arm64-probe
image: powershell
environment: &rid-linux-musl-arm64-probe
<<: *rid-linux-musl-arm64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-linux-musl-arm64-enroll
image: powershell
environment: &rid-linux-musl-arm64-enroll
<<: *rid-linux-musl-arm64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-macos-x64-agent
image: powershell
environment: &rid-macos-x64-agent
<<: *build-environment
RID: osx-x64
commands: *build-commands
depends_on: []
- name: build-macos-x64-probe
image: powershell
environment: &rid-macos-x64-probe
<<: *rid-macos-x64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-macos-x64-enroll
image: powershell
environment: &rid-macos-x64-enroll
<<: *rid-macos-x64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-macos-arm64-agent
image: powershell
environment: &rid-macos-arm64-agent
<<: *build-environment
RID: osx-arm64
commands: *build-commands
depends_on: []
- name: build-macos-arm64-probe
image: powershell
environment: &rid-macos-arm64-probe
<<: *rid-macos-arm64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-macos-arm64-enroll
image: powershell
environment: &rid-macos-arm64-enroll
<<: *rid-macos-arm64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-windows-x64-agent
image: powershell
environment: &rid-windows-x64-agent
<<: *build-environment
RID: win-x64
commands: *build-commands
depends_on: []
- name: build-windows-x64-probe
image: powershell
environment: &rid-windows-x64-probe
<<: *rid-windows-x64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-windows-x64-enroll
image: powershell
environment: &rid-windows-x64-enroll
<<: *rid-windows-x64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: build-windows-arm64-agent
image: powershell
environment: &rid-windows-arm64-agent
<<: *build-environment
RID: win-arm64
commands: *build-commands
depends_on: []
- name: build-windows-arm64-probe
image: powershell
environment: &rid-windows-arm64-probe
<<: *rid-windows-arm64-agent
COMPONENT: probe
commands: *build-commands
depends_on: []
- name: build-windows-arm64-enroll
image: powershell
environment: &rid-windows-arm64-enroll
<<: *rid-windows-arm64-agent
COMPONENT: fleet-enroll
commands: *build-commands
depends_on: []
- name: package-and-upload
image: powershell
environment: *build-environment
commands:
- Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
- ./ci/package-cross-platform.ps1
depends_on:
- build-linux-x64-agent
- build-linux-x64-probe
- build-linux-x64-enroll
- build-linux-arm64-agent
- build-linux-arm64-probe
- build-linux-arm64-enroll
- build-linux-musl-x64-agent
- build-linux-musl-x64-probe
- build-linux-musl-x64-enroll
- build-linux-musl-arm64-agent
- build-linux-musl-arm64-probe
- build-linux-musl-arm64-enroll
- build-macos-x64-agent
- build-macos-x64-probe
- build-macos-x64-enroll
- build-macos-arm64-agent
- build-macos-arm64-probe
- build-macos-arm64-enroll
- build-windows-x64-agent
- build-windows-x64-probe
- build-windows-x64-enroll
- build-windows-arm64-agent
- build-windows-arm64-probe
- build-windows-arm64-enroll
+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>
<PropertyGroup>
<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>
<ItemGroup>
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<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.Extensions.ApiDescription.Server" Version="10.0.12" />
<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 -63
View File
@@ -1,93 +1,115 @@
# Monitor
# MoShiro
Monitor 是一个自托管的网络线路质量观测工具。它在 Probe 与多个 Agent 之间执行主动网络测量,记录逐包和逐事件结果;Backend 保存原始数据并生成可查询的线路指标,Web 界面用于查看、比较和管理节点。
MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 Probe(例如家宽出口)与多台 Agent(例如海外 VPS)之间做主动网络测量,只记录逐包、逐事件的原始数据;Backend 保存这些原始数据,推算延迟、丢包、抖动、吞吐等指标,并回答「如果在这条线路上部署某类服务,体验会怎样」;Web 界面用来查看、比较和管理。
它面向需要比较不同网络出口、评估线路稳定性,或观察特定网络服务体验的场景。Agent 与 Probe 专注于测量和上报;它们不提供远程终端、命令执行或文件管理能力。
## 项目边界
## 能力概览
**它做什么**
- **主动测量**:组合 TCP、UDP 等测量模块,采集延迟、丢包、抖动、吞吐和连接事件。
- **线路分析**:从原始测量数据生成时间序列指标,并比较线路在不同服务模型下的体验。
- **节点管理**:通过 Fleet 节点框架处理身份、注册、连接、任务同步和数据上报。
- **实时面板**:展示端查看线路状态和历史趋势;管理端负责节点、任务和系统配置。
- **自托管**:Backend 使用段文件和 SQLite 保存数据;提供 Linux、systemd 和 OpenWrt 相关部署文件。
- 主动测量:组合 TCP、UDP、TLS、ICMP 等采集积木,只记录时间、序号、大小这类原始事实,**不转发也不记录业务内容**。
- 线路分析:Backend 从原始数据生成时间序列指标,按服务模型(REALITY、Hysteria2、Minecraft 等)评分并对多条线路排序、对比;模型和指标可以事后重算。
- 管理:节点注册与审批、Probe 和 Agent 之间连哪些线路、任务与按需测试、节点版本与更新,都在管理端完成。
**它不做什么**
- **节点是纯采集器**: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
Probe <──测量会话──> Agent ──长连接与数据上报──> Backend <──REST / SSE──> Web 展示端与管理端
├── 段文件:原始数据
└── SQLite:索引、指标与配置
Web(展示端 · 管理端)
▲ REST + SSE
┌──────────────────────────────────────────────────────────┐
│ 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 决定:默认自动全连,也可以在管理端手动选择。
### 环境要求
[README.md](backend/README.md)
- .NET SDK 10;项目使用 `backend/global.json` 指定 SDK 版本和滚动策略。
- Node.js 与 npm,用于构建 Web 前端。
```text
采集(只记录) 传输(有界、至少一次) 分析(注册制、可重算) 展示
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
cd backend
dotnet build Monitor.slnx -c Release
dotnet build MoShiro.slnx -c Release # Backend、Agent、Probe、Fleet、测试和 CI 工具
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
npm ci --prefix web
npm run build --prefix web
dotnet test --solution MoShiro.slnx # 后端和节点的测试
npm run lint:css --prefix web # 前端样式(颜色只能写在主题令牌里)
npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型
```
前端产物位于 `web/display/dist/` 和 `web/admin/dist/`。NativeAOT 发布还需要对应平台的 C/C++ 工具链;Linux 发布脚本和 RID 说明见 [`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)。
### 本地运行
## 文档
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 测试 |
| [`backend/agent/`](backend/agent/README.md) | Agent 节点:注册到 Backend,接受 Probe 测量并上报数据 |
| [`backend/probe/`](backend/probe/README.md) | Probe 节点:发起线路测量并将采集数据交给 Agent |
| `backend/fleet/` | 可复用的节点身份、注册和连接框架 |
| `backend/shared/` | 测量引擎、协议和原始数据格式 |
| `web/display/`、`web/admin/` | 展示端和管理端 |
| `web/shared/` | 前端 API、图表、组件与主题 |
| `packaging/`、`scripts/` | 部署、构建与维护脚本 |
| `docs/` | [架构](docs/architecture.md)、[测量模型](docs/measurement.md)、[API](docs/api.md)、[部署](docs/deployment.md)等文档 |
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
| [`docs/storage.md`](docs/storage.md)、[`docs/query.md`](docs/query.md) | 存储、展示数据查询 |
| [`docs/api.md`](docs/api.md)、[`docs/api-guidelines.md`](docs/api-guidelines.md) | API 规范与约束 |
| [`docs/deployment.md`](docs/deployment.md) | 构建、发布、CI、部署与更新 |
| [`docs/project-layout.md`](docs/project-layout.md)、[`docs/coding-style.md`](docs/coding-style.md) | 仓库结构与依赖规则、代码风格 |
## 测试
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
```sh
cd backend
dotnet test --solution Monitor.slnx
```
## 待办
```sh
# 前端样式检查(仓库根目录)
npm run lint:css --prefix web
```
见 [`TODO.md`](TODO.md)。
## 许可证
+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 服务适配。
+3 -1
View File
@@ -5,13 +5,15 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<InvariantGlobalization>true</InvariantGlobalization>
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic>
<Version>0.1.0</Version>
<Version>0.1.1</Version>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
+16 -16
View File
@@ -17,33 +17,33 @@
<Project Path="fleet/tools/fleet-enroll/fleet-enroll.csproj" />
</Folder>
<Folder Name="/shared/">
<Project Path="shared/Monitor.Measurement/Monitor.Measurement.csproj" />
<Project Path="shared/Monitor.Protocol/Monitor.Protocol.csproj" />
<Project Path="shared/MoShiro.Measurement/MoShiro.Measurement.csproj" />
<Project Path="shared/MoShiro.Protocol/MoShiro.Protocol.csproj" />
</Folder>
<Folder Name="/shared/tests/">
<Project Path="shared/tests/Monitor.Measurement.Tests/Monitor.Measurement.Tests.csproj" />
<Project Path="shared/tests/Monitor.Protocol.Tests/Monitor.Protocol.Tests.csproj" />
<Project Path="shared/tests/MoShiro.Measurement.Tests/MoShiro.Measurement.Tests.csproj" />
<Project Path="shared/tests/MoShiro.Protocol.Tests/MoShiro.Protocol.Tests.csproj" />
</Folder>
<Folder Name="/agent/src/">
<Project Path="agent/src/Monitor.Agent/Monitor.Agent.csproj" />
<Project Path="agent/src/MoShiro.Agent/MoShiro.Agent.csproj" />
</Folder>
<Folder Name="/probe/src/">
<Project Path="probe/src/Monitor.Probe/Monitor.Probe.csproj" />
<Project Path="probe/src/MoShiro.Probe/MoShiro.Probe.csproj" />
</Folder>
<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 Name="/backend/src/">
<Project Path="src/Monitor.Backend.Analysis/Monitor.Backend.Analysis.csproj" />
<Project Path="src/Monitor.Backend.Api/Monitor.Backend.Api.csproj" />
<Project Path="src/Monitor.Backend.Application/Monitor.Backend.Application.csproj" />
<Project Path="src/Monitor.Backend.Gateway/Monitor.Backend.Gateway.csproj" />
<Project Path="src/Monitor.Backend.Storage.Sqlite/Monitor.Backend.Storage.Sqlite.csproj" />
<Project Path="src/Monitor.Backend/Monitor.Backend.csproj" />
<Project Path="src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
<Project Path="src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj" />
<Project Path="src/MoShiro.Backend.Application/MoShiro.Backend.Application.csproj" />
<Project Path="src/MoShiro.Backend.Gateway/MoShiro.Backend.Gateway.csproj" />
<Project Path="src/MoShiro.Backend.Storage.Sqlite/MoShiro.Backend.Storage.Sqlite.csproj" />
<Project Path="src/MoShiro.Backend/MoShiro.Backend.csproj" />
</Folder>
<Folder Name="/backend/tests/">
<Project Path="tests/Monitor.Backend.Analysis.Tests/Monitor.Backend.Analysis.Tests.csproj" />
<Project Path="tests/Monitor.Backend.Tests/Monitor.Backend.Tests.csproj" />
<Project Path="tests/Monitor.TestSupport/Monitor.TestSupport.csproj" />
<Project Path="tests/MoShiro.Backend.Analysis.Tests/MoShiro.Backend.Analysis.Tests.csproj" />
<Project Path="tests/MoShiro.Backend.Tests/MoShiro.Backend.Tests.csproj" />
<Project Path="tests/MoShiro.TestSupport/MoShiro.TestSupport.csproj" />
</Folder>
</Solution>
+16 -14
View File
@@ -1,24 +1,24 @@
# Backend
Backend 是 Monitor 的控制面和数据服务,负责 Agent 注册与连接、任务编排、原始测量数据持久化、指标分析,以及供前端使用的 REST API 和 SSE 实时数据流。
Backend 是 MoShiro 的控制面和数据服务,负责 Agent 注册与连接、任务编排、原始测量数据持久化、指标分析,以及供前端使用的 REST API 和 SSE 实时数据流。
## 组件
| 路径 | 职责 |
| --- | --- |
| `src/Monitor.Backend/` | 应用入口与依赖组合 |
| `src/Monitor.Backend.Api/` | Public、Admin API 和 OpenAPI |
| `src/Monitor.Backend.Gateway/` | Fleet RPC、节点状态与测量数据接收 |
| `src/Monitor.Backend.Application/` | 业务用例、查询、任务和后台服务 |
| `src/Monitor.Backend.Analysis/` | 指标计算、累加器和服务模型 |
| `src/Monitor.Backend.Storage.Sqlite/` | 段文件与 SQLite 存储 |
| `src/MoShiro.Backend/` | 应用入口与依赖组合 |
| `src/MoShiro.Backend.Api/` | Public、Admin API 和 OpenAPI |
| `src/MoShiro.Backend.Gateway/` | Fleet RPC、节点状态与测量数据接收 |
| `src/MoShiro.Backend.Application/` | 业务用例、查询、任务和后台服务 |
| `src/MoShiro.Backend.Analysis/` | 指标计算、累加器和服务模型 |
| `src/MoShiro.Backend.Storage.Sqlite/` | 段文件与 SQLite 存储 |
| `fleet/` | 通用节点身份、注册和连接框架 |
| `shared/` | 测量引擎、协议和数据格式 |
| `tests/` | 集成测试和测试支持代码 |
## 环境要求
使用 .NET SDK 10。仓库根目录的 `backend/global.json` 指定所需 SDK 版本及滚动策略。
使用 .NET SDK 10。仓库根目录的 `global.json` 指定所需 SDK 版本及滚动策略。
## 构建与测试
@@ -26,17 +26,19 @@ Backend 是 Monitor 的控制面和数据服务,负责 Agent 注册与连接
```sh
cd backend
dotnet build Monitor.slnx -c Release
dotnet test --solution Monitor.slnx
dotnet build MoShiro.slnx -c Release
dotnet test --solution MoShiro.slnx
```
发布当前平台的 NativeAOT Backend:
```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-musl-x64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh` 也提供发布和容器构建流程,详见 [`../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)。
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。
## 本地运行
@@ -44,9 +46,9 @@ dotnet publish src/Monitor.Backend -c Release -r <RID> -p:PublishAot=true
```sh
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)。
+4
View File
@@ -8,6 +8,10 @@
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup>
+13 -7
View File
@@ -1,4 +1,4 @@
# Monitor Agent
# MoShiro 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
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
```
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```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
```
@@ -36,7 +36,7 @@ dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
先在管理端创建加入令牌,然后在 Agent 主机上运行一次性注册工具:
```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 执行目标探测任务。
@@ -44,10 +44,14 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
启动 Agent:
```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/` 提供。
- 详细安全边界与拓扑见 [`../../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.Link;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace Monitor.Agent;
namespace MoShiro.Agent;
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
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 bool _ipv6Supported;
private readonly LoadedNodeIdentity _identity;
private readonly TimeProvider _time;
private readonly INodeLog _log;
@@ -25,6 +26,7 @@ public sealed class AgentApp : IDisposable
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AgentAdmission _admission;
private readonly Outbox _outbox = new(64 << 20);
private readonly UpdateBridge _update;
private readonly BackendUplink _uplink;
private readonly UdpMux _udp;
private readonly TcpAcceptor _tcp;
@@ -34,8 +36,10 @@ public sealed class AgentApp : IDisposable
private readonly TestRunner _tests;
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;
_identity = identity;
_time = time;
@@ -44,6 +48,12 @@ public sealed class AgentApp : IDisposable
_uplink = new BackendUplink(time);
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);
_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);
@@ -182,39 +192,49 @@ public sealed class AgentApp : IDisposable
HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null;
bool Allow(string method) => allowed is null || allowed.Contains(method);
if (Allow(MonitorMethods.TestRun))
if (Allow(MoShiroMethods.TestRun))
{
router.On(
MonitorMethods.TestRun,
MonitorJsonContext.Default.TestRunParams,
MonitorJsonContext.Default.TestRunResult,
MoShiroMethods.TestRun,
MoShiroJsonContext.Default.TestRunParams,
MoShiroJsonContext.Default.TestRunResult,
(_, run, _) => ValueTask.FromResult(_tests.Start(run)));
}
if (Allow(MonitorMethods.TestCancel))
if (Allow(MoShiroMethods.TestCancel))
{
router.On(
MonitorMethods.TestCancel,
MonitorJsonContext.Default.TestCancelParams,
MonitorJsonContext.Default.TestRunResult,
MoShiroMethods.TestCancel,
MoShiroJsonContext.Default.TestCancelParams,
MoShiroJsonContext.Default.TestRunResult,
(_, cancel, _) => ValueTask.FromResult(Result<TestRunResult>.Ok(new TestRunResult { Accepted = _tests.Cancel(cancel.TestId) })));
}
if (Allow(MonitorMethods.ProbeDisconnect))
if (Allow(MoShiroMethods.UpdateOffer))
{
router.On(
MonitorMethods.ProbeDisconnect,
MonitorJsonContext.Default.ProbeDisconnectParams,
MonitorJsonContext.Default.TestRunResult,
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) }));
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
_update.WriteOffer(offer);
return ValueTask.CompletedTask;
});
}
if (Allow(MonitorMethods.ProbeAgentsChanged))
if (Allow(MoShiroMethods.UpdateApply))
{
router.OnNotify(
MonitorMethods.ProbeAgentsChanged,
MonitorJsonContext.Default.ProbeAgentsResult,
(_, list, _) => _hub.RelayAgentsAsync(list));
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
_update.RequestApply();
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
@@ -223,11 +243,11 @@ public sealed class AgentApp : IDisposable
Identity = _identity.Identity,
NodeId = _identity.NodeId,
NodeVersion = Version,
Capabilities = [MonitorCapabilities.Base, MonitorCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
HelloMeta = HelloMeta,
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)
link.OnConnected = async (_, _) =>
@@ -244,6 +264,9 @@ public sealed class AgentApp : IDisposable
{
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,
ProbeTcpPort = ControlPort,
ProbeUdpPorts = [.. UdpPorts],
@@ -255,7 +278,7 @@ public sealed class AgentApp : IDisposable
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
},
};
return JsonSerializer.SerializeToElement(meta, MonitorJsonContext.Default.AgentHelloMeta);
return JsonSerializer.SerializeToElement(meta, MoShiroJsonContext.Default.AgentHelloMeta);
}
private long _lastIn;
@@ -270,6 +293,7 @@ public sealed class AgentApp : IDisposable
long packetsOut = _counters.PacketsOut;
var extra = new AgentHeartbeatExtra
{
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Probes = [.. _hub.ConnectedProbes()],
Sessions = _sessions.All.Count,
PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds,
@@ -277,10 +301,11 @@ public sealed class AgentApp : IDisposable
DroppedInvalid = _counters.DroppedInvalid,
DroppedRateLimited = _counters.DroppedRateLimited,
QueueBytes = _outbox.PendingBytes,
Update = _update.ReadStatus(),
};
_lastIn = packetsIn;
_lastOut = packetsOut;
_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;
namespace Monitor.Agent;
namespace MoShiro.Agent;
/// <summary>
/// <c>agent.json</c>:节点本地配置,面板无法修改。其中 <c>limits</c> 是本地硬上限:期望任务集超出这些范围时,
@@ -40,13 +40,13 @@ public sealed class FleetSection
public string Server { get; set; } = "";
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/monitor";
public string IdentityDir { get; set; } = "/etc/moshiro";
}
public sealed class ListenSection
{
[JsonPropertyName("bind")]
public string Bind { get; set; } = "0.0.0.0";
public string Bind { get; set; } = "::";
/// <summary>Probe 控制通道(TLS,点对点会话)。</summary>
[JsonPropertyName("control_port")]
@@ -1,9 +1,9 @@
using Fleet.Core;
using Fleet.Link;
using Monitor.Measurement;
using Monitor.Protocol;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace Monitor.Agent;
namespace MoShiro.Agent;
/// <summary>
/// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
@@ -44,6 +44,7 @@ public sealed class ChunkAssembler
_ => null,
};
header.ProbeId = new ProbeId(probe.ProbeId);
header.IpVersion = probe.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind);
if (stream is null || spec is null || spec.Id != header.Kind)
{
@@ -70,6 +71,6 @@ public sealed class ChunkAssembler
/// <summary>Agent 自己采集的块(目标任务)直接发出。</summary>
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.Link;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace Monitor.Agent;
namespace MoShiro.Agent;
/// <summary>进程内计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。数据面的包计数由共享的 <see cref="DataPlaneCounters"/> 提供。</summary>
public sealed class AgentCounters
@@ -56,6 +56,10 @@ public sealed class ProbeSession : DataSession
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>
public string? Version => Info.Version;
@@ -72,7 +76,7 @@ public sealed class ProbeSession : DataSession
get
{
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 _);
}
/// <summary>当前连着的 Probe 会话:每个 Probe 一个;同一个 Probe 重连时新会话取代它自己的旧会话。</summary>
/// <summary>每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
public sealed class SessionRegistry : IDataSessions
{
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)
{
return _byProbe.GetValueOrDefault(probeId);
return _byProbe.GetValueOrDefault(probeId + "~" + ipVersion);
}
}
@@ -156,13 +160,13 @@ public sealed class SessionRegistry : IDataSessions
{
lock (_gate)
{
_byProbe.TryGetValue(session.ProbeId, out ProbeSession? old);
_byProbe.TryGetValue(session.PathKey, out ProbeSession? old);
if (old is not null)
{
_bySession.Remove(old.SessionId);
}
_byProbe[session.ProbeId] = session;
_byProbe[session.PathKey] = session;
_bySession[session.SessionId] = session;
return old;
}
@@ -172,9 +176,9 @@ public sealed class SessionRegistry : IDataSessions
{
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))
@@ -1,28 +1,35 @@
using System.Globalization;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Agent;
namespace MoShiro.Agent;
/// <summary>
/// 主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<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>
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 Func<string, string?> _read;
private readonly Func<(long Total, long Used)?> _disk;
private readonly string _procRoot;
private readonly string _sysRoot;
private (long Total, long Idle)? _previousCpu;
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;
_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()
@@ -30,7 +37,7 @@ public sealed class HostSampler
long now = _time.GetTimestamp();
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)
{
@@ -41,7 +48,7 @@ public sealed class HostSampler
_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.MemUsed = memory.Used;
@@ -53,10 +60,11 @@ public sealed class HostSampler
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.NetTxTotal = net.Tx;
snapshot.NetLinkMbps = ReadLinkSpeed(dev);
if (_previousNet is { } before && net.Rx >= before.Rx && net.Tx >= before.Tx)
{
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);
}
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;
}
@@ -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
{
string root = OperatingSystem.IsWindows() ? Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\" : "/";
if (!Directory.Exists(root))
{
return null;
}
var drive = new DriveInfo(root);
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.Link;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace Monitor.Agent;
namespace MoShiro.Agent;
/// <summary>Agent → Backend 的上行:事件和测试状态是尽力而为的通知(链路断开时丢弃;列块走发件箱,不会丢)。</summary>
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 };
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)
@@ -42,7 +42,7 @@ public sealed class BackendUplink
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)
@@ -54,19 +54,7 @@ public sealed class BackendUplink
}
var parameters = new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode };
await SafeAsync(() => session.NotifyAsync(MonitorMethods.TestStatus, parameters, MonitorJsonContext.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);
await SafeAsync(() => session.NotifyAsync(MoShiroMethods.TestStatus, parameters, MoShiroJsonContext.Default.TestStatusParams, CancellationToken.None));
}
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)
{
_router = BuildRouter(uplink);
_router = BuildRouter();
_sessions = sessions;
_clock = clock;
_chunks = chunks;
@@ -127,7 +115,7 @@ public sealed class PeerHub
var order = new Queue<Guid>();
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;
}
@@ -188,7 +176,7 @@ public sealed class PeerHub
await _tasks.CloseSessionAsync(probe);
_log.Info("probe disconnected", ("probe", info.PeerId));
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>
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)];
@@ -230,13 +218,17 @@ public sealed class PeerHub
/// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
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 true;
return disconnected;
}
/// <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)
{
if (probe.Rpc is null)
@@ -284,7 +255,7 @@ public sealed class PeerHub
{
Type = LineTaskSet.StateType,
Version = set.Version,
Doc = JsonSerializer.SerializeToElement(set, MonitorJsonContext.Default.LineTaskSet),
Doc = JsonSerializer.SerializeToElement(set, MoShiroJsonContext.Default.LineTaskSet),
};
try
{
@@ -300,16 +271,11 @@ public sealed class PeerHub
}
}
private static RpcRouter<PeerContext> BuildRouter(BackendUplink uplink) =>
private static RpcRouter<PeerContext> BuildRouter() =>
new RpcRouter<PeerContext>()
.On(
MonitorMethods.ProbeAgents,
MonitorJsonContext.Default.ProbeAgentsParams,
MonitorJsonContext.Default.ProbeAgentsResult,
(_, request, ct) => uplink.RequestProbeAgentsAsync(request, ct))
.On(
MonitorMethods.Clock,
MonitorJsonContext.Default.ClockParams,
MonitorJsonContext.Default.ClockResult,
MoShiroMethods.Clock,
MoShiroJsonContext.Default.ClockParams,
MoShiroJsonContext.Default.ClockResult,
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() })));
}
@@ -1,6 +1,6 @@
using Fleet.Core;
using Fleet.Hosting;
using Monitor.Agent;
using MoShiro.Agent;
TimeProvider time = TimeProvider.System;
INodeLog log = new ConsoleNodeLog(time);
@@ -11,7 +11,7 @@ if (PlatformCheck.VerifyCrypto() is { } problem)
return 1;
}
string configPath = "/etc/monitor/agent.json";
string configPath = "/etc/moshiro/agent.json";
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i] == "--config")
@@ -35,7 +35,7 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
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);
}
@@ -1,10 +1,10 @@
using System.Net;
using System.Text.Json;
using Fleet.Core;
using Monitor.Measurement;
using Monitor.Protocol;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace Monitor.Agent;
namespace MoShiro.Agent;
/// <summary>
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect),
@@ -80,7 +80,7 @@ public sealed class TargetRunners
Log = _log,
Session = () => null,
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)));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -132,11 +132,13 @@ public sealed class TargetRunners
{
private readonly TargetRunners _owner;
private readonly TaskSpec _spec;
private readonly string _ipVersion;
public LocalSink(TargetRunners owner, TaskSpec spec)
public LocalSink(TargetRunners owner, TaskSpec spec, string ipVersion)
{
_owner = owner;
_spec = spec;
_ipVersion = ipVersion;
}
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,
AgentId = _owner._agent,
Target = _spec.Target,
IpVersion = _ipVersion,
Params = parameters is null ? [] : new Dictionary<string, JsonElement>(parameters),
Port = port,
StartUtcMs = startUtcMs,
@@ -1,10 +1,10 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace Monitor.Agent;
namespace MoShiro.Agent;
/// <summary>
/// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。
@@ -1,15 +1,15 @@
using System.Collections.Concurrent;
using Fleet.Core;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace Monitor.Agent;
namespace MoShiro.Agent;
/// <summary>
/// 按需测试的编排(<c>monitor.test.run</c>):Agent 是时间的主人。每个阶段:
/// 登记数据面的流 → <c>monitor.test.phase</c>(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包)
/// → <c>monitor.test.phase_end</c>(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
/// 按需测试的编排(<c>moshiro.test.run</c>):Agent 是时间的主人。每个阶段:
/// 登记数据面的流 → <c>moshiro.test.phase</c>(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包)
/// → <c>moshiro.test.phase_end</c>(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
/// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。
/// </summary>
public sealed class TestRunner
@@ -43,17 +43,22 @@ public sealed class TestRunner
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)
{
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)
{
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)>();
@@ -64,7 +69,7 @@ public sealed class TestRunner
if (!decision.Accepted)
{
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}");
}
@@ -84,7 +89,7 @@ public sealed class TestRunner
if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running))
{
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);
@@ -154,7 +159,7 @@ public sealed class TestRunner
catch (OperationCanceledException)
{
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);
}
catch (TestFailure failure)
@@ -183,7 +188,7 @@ public sealed class TestRunner
_streams.Add(stream);
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
{
TestId = test,
@@ -194,7 +199,7 @@ public sealed class TestRunner
DurationSeconds = durationSeconds,
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)
{
throw new TestFailure(error?.Code.Value ?? FleetErrors.Unsupported.Value);
@@ -209,7 +214,7 @@ public sealed class TestRunner
}
catch (BlockFaultException)
{
throw new TestFailure(MonitorErrors.ProbeOffline.Value);
throw new TestFailure(MoShiroErrors.ProbeOffline.Value);
}
}
else
@@ -219,7 +224,7 @@ public sealed class TestRunner
long sent = stream.Running.Block.Produced;
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))
{
throw new TestFailure(endError.Code.Value);
@@ -254,7 +259,7 @@ public sealed class TestRunner
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)
{
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<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>
</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;
/// <summary>节点 ID(带类型前缀的 ULID)。Monitor 中的 AgentId 就是 NodeId。</summary>
/// <summary>节点 ID(带类型前缀的 ULID)。MoShiro 中的 AgentId 就是 NodeId。</summary>
[JsonConverter(typeof(NodeIdJsonConverter))]
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");
}
Directory.CreateDirectory(_directory);
// 相对路径里的子目录(例如 update/)也要建:新建的命名卷里只有镜像自带的目录
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
string temp = path + ".tmp";
#pragma warning disable RS0030 // 纯采集边界的唯一豁免点:ConfigStore 只写配置目录,先写临时文件再原子替换
File.WriteAllBytes(temp, content);
+1 -1
View File
@@ -49,7 +49,7 @@ public sealed class FleetLinkOptions
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; } = [];
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
+7 -1
View File
@@ -57,6 +57,11 @@ public sealed class PeerListener
public async Task RunAsync(CancellationToken ct)
{
var listener = new TcpListener(_options.Listen);
if (_options.Listen.Address.Equals(IPAddress.IPv6Any))
{
listener.Server.DualMode = true;
}
listener.Start();
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
Started.TrySetResult();
@@ -219,7 +224,8 @@ public static class PeerConnector
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
{
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);
try
{
+1 -1
View File
@@ -58,7 +58,7 @@ public sealed class HelloParams
[JsonPropertyName("now_utc_ms")]
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")]
public JsonElement? Meta { get; set; }
}
@@ -2,7 +2,7 @@ namespace Fleet.Server;
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 int MaxFrameBytes { get; set; } = 16 << 20;
@@ -2,7 +2,7 @@ using Fleet.Core;
namespace Fleet.Server;
/// <summary>Fleet 服务端需要的存储。产品可以自己实现(Monitor 把它放进 monitor.db)。</summary>
/// <summary>Fleet 服务端需要的存储。产品可以自己实现(MoShiro 把它放进 moshiro.db)。</summary>
public interface IFleetStore
{
ValueTask<JoinTokenRecord?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenSha256, CancellationToken ct);
@@ -50,7 +50,7 @@ public sealed class FleetServerBuilder
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)
{
_routeConfigurators.Add(configure);
+1 -1
View File
@@ -2,7 +2,7 @@ using System.Text.Json.Nodes;
using Fleet.Core;
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();
@@ -1,11 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>fleet-enroll</AssemblyName>
<RootNamespace>FleetEnroll</RootNamespace>
<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>
<ItemGroup>
<ProjectReference Include="..\..\src\Fleet.Enrollment.Client\Fleet.Enrollment.Client.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
</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>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</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
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
cd backend
dotnet build probe/src/Monitor.Probe -c Release
dotnet build probe/src/MoShiro.Probe -c Release
```
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```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)。
## 配置与运行
在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/monitor/probe.json`。随后运行:
在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/moshiro/probe.json`。随后运行:
```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,不落盘测量数据,也不能执行命令或自行更新。
- 测量模块由 `backend/shared/Monitor.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
- Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。
- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。
- 测量模型和协议细节见 [`../../docs/measurement.md`](../../docs/measurement.md) 与 [`../../docs/protocol.md`](../../docs/protocol.md);整体架构见 [`../../docs/architecture.md`](../../docs/architecture.md)。
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.Link;
using Fleet.Rpc;
using Monitor.Measurement;
using Monitor.Protocol;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace Monitor.Probe;
namespace MoShiro.Probe;
/// <summary>所有连接共用的东西。</summary>
public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
/// <summary>
/// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 取 Agent 列表 → 按 Agent 下发的 <c>line_task_set</c> 运行角色 →
/// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 按 Agent 下发的 <c>line_task_set</c> 运行角色 →
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
/// </summary>
@@ -28,13 +28,13 @@ public sealed class AgentConnection
private readonly ConnectionEnv _env;
private volatile ConnectionSession? _session;
public AgentConnection(BootstrapAgent endpoint, ConnectionEnv env)
public AgentConnection(KnownAgent endpoint, ConnectionEnv env)
{
Endpoint = endpoint;
_env = env;
}
public BootstrapAgent Endpoint { get; }
public KnownAgent Endpoint { get; }
public bool IsConnected => _session is not null;
@@ -74,8 +74,20 @@ public sealed class AgentConnection
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(
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 data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
@@ -105,12 +117,11 @@ public sealed class AgentConnection
Task pump = PumpAsync(rpc, outbox, cts.Token);
try
{
// 先同步时钟(块头里要带偏移),再取 Agent 列表;Agent 随后推送线路任务集
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
backoff.Reset();
_session = session;
_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 finished = await Task.WhenAny(run, receive, pump, maintenance);
await cts.CancelAsync();
@@ -155,7 +166,7 @@ public sealed class AgentConnection
for (int i = 0; i < rounds; i++)
{
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 _))
{
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>
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 };
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)
{
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);
})
.On(
MonitorMethods.TestPhase,
MonitorJsonContext.Default.TestPhaseStartParams,
MonitorJsonContext.Default.TestPhaseStartResult,
MoShiroMethods.TestPhase,
MoShiroJsonContext.Default.TestPhaseStartParams,
MoShiroJsonContext.Default.TestPhaseStartResult,
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
.On(
MonitorMethods.TestPhaseEnd,
MonitorJsonContext.Default.TestPhaseEndParams,
MonitorJsonContext.Default.TestPhaseEndResult,
MoShiroMethods.TestPhaseEnd,
MoShiroJsonContext.Default.TestPhaseEndParams,
MoShiroJsonContext.Default.TestPhaseEndResult,
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
.OnNotify(
MonitorMethods.TestAbort,
MonitorJsonContext.Default.TestAbortParams,
MoShiroMethods.TestAbort,
MoShiroJsonContext.Default.TestAbortParams,
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(
FleetMethods.OutboxAck,
FleetRpcContext.Default.OutboxAckParams,
@@ -1,13 +1,12 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Hosting;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Probe;
namespace MoShiro.Probe;
/// <summary>
/// Probe 要连接的 Agent 列表。Probe 不认识 Backend:列表先取自 <c>probe.json</c> 的 bootstrap(或上一次保存的 <c>agents.json</c>),
/// 连上任意一个 Agent 之后,由它代为向 Backend 查询(<c>monitor.probe.agents</c>),之后的变化由 Agent 推送(<c>agents_changed</c>)。
/// Probe 要连接的 Agent 列表:由面板作为期望状态(<c>probe_agents</c>)直接下发;面板连不上时用上一次保存的 <c>agents.json</c>。
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
/// </summary>
public sealed class AgentDirectory
@@ -17,29 +16,25 @@ public sealed class AgentDirectory
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
private List<BootstrapAgent> _agents;
private List<KnownAgent> _agents = [];
private long _version;
public AgentDirectory(ConfigStore store, string configDirectory, ProbeFile file, INodeLog log)
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log)
{
_store = store;
_log = log;
_agents = [.. file.Bootstrap];
string cache = Path.Combine(configDirectory, FileName);
if (File.Exists(cache))
{
try
{
AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache);
if (saved.Agents.Count > 0)
{
_agents = saved.Agents;
_version = saved.Version;
}
_agents = saved.Agents;
_version = saved.Version;
}
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
{
@@ -69,10 +64,22 @@ public sealed class AgentDirectory
}
}
/// <summary>应用 Agent 转来的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
public void Apply(ProbeAgentsResult result)
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
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)
{
if (result.Version < _version || (result.Version == _version && Same(next, _agents)))
@@ -97,9 +104,9 @@ public sealed class AgentDirectory
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));
public static bool Equal(BootstrapAgent a, BootstrapAgent b) =>
a.AgentId == b.AgentId && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki;
public static bool Equal(KnownAgent a, KnownAgent b) =>
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki;
}
@@ -1,13 +1,13 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Link;
using Monitor.Measurement;
using Monitor.Protocol;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace Monitor.Probe;
namespace MoShiro.Probe;
/// <summary>
/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>monitor.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>moshiro.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
/// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
/// </summary>
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 MoShiro.Protocol;
namespace Monitor.Probe;
namespace MoShiro.Probe;
/// <summary>
/// <c>probe.json</c>:面板 <c>POST /api/admin/probe/identity</c> 生成的文件(只能下载一次)。
@@ -15,16 +16,22 @@ public sealed class ProbeFile
[JsonPropertyName("private_key")]
public string PrivateKey { get; set; } = "";
/// <summary>首次连接用的 Agent;连上之后以 <c>agents.json</c>(最新的列表)为准。</summary>
[JsonPropertyName("bootstrap")]
public List<BootstrapAgent> Bootstrap { get; set; } = [];
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
[JsonPropertyName("server")]
public string Server { get; set; } = "";
[JsonPropertyName("limits")]
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")]
public string AgentId { get; set; } = "";
@@ -54,12 +61,12 @@ public sealed class ProbeLimits
[JsonSerializable(typeof(AgentsCache))]
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
{
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("agents")]
public List<BootstrapAgent> Agents { get; set; } = [];
public List<KnownAgent> Agents { get; set; } = [];
}
@@ -1,6 +1,6 @@
using Fleet.Core;
using Fleet.Hosting;
using Monitor.Probe;
using MoShiro.Probe;
TimeProvider time = TimeProvider.System;
INodeLog log = new ConsoleNodeLog(time);
@@ -11,7 +11,7 @@ if (PlatformCheck.VerifyCrypto() is { } problem)
return 1;
}
string configPath = "/etc/monitor/probe.json";
string configPath = "/etc/moshiro/probe.json";
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i] == "--config")
@@ -24,9 +24,9 @@ ProbeFile file;
try
{
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)
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<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>
</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>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\src\Monitor.Probe\Monitor.Probe.csproj" />
<ProjectReference Include="..\..\src\MoShiro.Probe\MoShiro.Probe.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
@@ -1,11 +1,36 @@
using Fleet.Core;
using Fleet.Hosting;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Probe.Tests;
namespace MoShiro.Probe.Tests;
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]
public void ClockEstimator_UsesTheSampleWithTheShortestRoundTrip()
{
@@ -30,23 +55,22 @@ public sealed class ProbeTests
return path;
}
private static ProbeAgentsResult List(long version, params string[] ids) => new()
private static ProbeAgentList List(long version, params string[] ids) => new()
{
Version = version,
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, Spki = "c3BraQ==" })],
};
[Fact]
public void AgentDirectory_StartsFromBootstrap_IgnoresStaleLists_PersistsChanges_AndReloadsThem()
public void AgentDirectory_StartsEmpty_IgnoresStaleLists_PersistsChanges_AndReloadsThem()
{
string directory = NewDirectory();
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, file, NullNodeLog.Instance);
var directoryA = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
int changes = 0;
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"));
Assert.Equal(1, changes);
@@ -57,8 +81,8 @@ public sealed class ProbeTests
Assert.Equal(1, changes);
Assert.Equal(5, directoryA.Version);
// The next start uses the saved list, not the bootstrap one
var directoryB = new AgentDirectory(new ConfigStore(directory), directory, file, NullNodeLog.Instance);
// The next start uses the saved list while the panel is not reachable yet
var directoryB = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Equal(["agt_1", "agt_2"], directoryB.Agents.Select(a => a.AgentId));
Assert.Equal(5, directoryB.Version);
}
@@ -69,15 +93,30 @@ public sealed class ProbeTests
}
[Fact]
public void AgentDirectory_FallsBackToBootstrap_WhenTheSavedFileIsCorrupt()
public void AgentDirectory_StartsEmpty_WhenTheSavedFileIsCorrupt()
{
string directory = NewDirectory();
try
{
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, file, NullNodeLog.Instance);
Assert.Equal(["agt_boot"], loaded.Agents.Select(a => a.AgentId));
var loaded = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Empty(loaded.Agents);
}
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
{
@@ -1,7 +1,7 @@
using System.Net;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>积木的公共部分:环境、参数、把行切成列块交出去。</summary>
public abstract class BlockBase : IBlock
@@ -1,6 +1,6 @@
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>
/// 一种采集方法(Kind)的两个角色:拨号方(Probe)和监听方(Agent),以及它能不能作为目标任务(Agent 自己探测一个地址,没有对端)运行。
@@ -1,7 +1,7 @@
using System.Net.Sockets;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>批量传输的收发循环(两端共用)。</summary>
internal static class BulkIo
@@ -1,8 +1,8 @@
using System.Net;
using System.Net.Sockets;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>
/// 回显积木的行表(拨号方,行的主导方):发出时记 size、t1,回复到达时按序号回填 t4,到点把已经稳定的行切成列块。
@@ -1,9 +1,10 @@
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Security.Authentication;
using Monitor.Protocol;
using Fleet.Core;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>什么也不做的监听方:connect 的另一端就是 TCP 监听器本身(接受连接、认证失败就关闭),不需要积木,但需要占一个流编号。</summary>
public sealed class IdleListener : BlockBase
@@ -306,7 +307,8 @@ public sealed class IcmpEchoSolo : BlockBase
TimeSpan interval = TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz"));
int size = (int)Spec.Int("size");
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
{
while (!ct.IsCancellationRequested)
@@ -315,8 +317,10 @@ public sealed class IcmpEchoSolo : BlockBase
long? t4 = null;
try
{
PingReply reply = await ping.SendPingAsync(Env.PeerAddress!, TimeSpan.FromSeconds(2), payload, null, ct);
if (reply.Status == IPStatus.Success)
bool receivedReply = linuxPing is not null
? 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();
}
@@ -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();
_log.Store.Set(key, _seq, key);
@@ -1,8 +1,8 @@
using System.Diagnostics;
using System.Net;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>
/// 单向数据流(UDP train),监听方(Agent,行的主导方):按声明的码率均匀发包,发出的每个包 (seq, size, t_send) 记在按序号的记录里,
@@ -1,9 +1,9 @@
using System.Net;
using System.Text.Json;
using Fleet.Core;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>
/// 数据面会话:<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>
public sealed class MonoClock
@@ -1,6 +1,6 @@
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>
/// 一侧记录的行:只含这一侧拥有的列(见 <see cref="StreamSpec.ColumnsOf"/>),按连接键(通常是 <c>seq</c>)索引的环形缓冲,
@@ -1,4 +1,4 @@
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。</summary>
public sealed class RunningBlock : IDisposable
@@ -1,8 +1,8 @@
using System.Net;
using System.Text.Json;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>节点的本地硬上限(写在本机配置里,Backend 无法修改)。</summary>
public sealed class NodeLimits
@@ -1,8 +1,8 @@
using System.Text.Json;
using Fleet.Core;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>Agent 单调时钟(微秒)与 UTC(毫秒)的对应关系:块头里的时钟锚点。</summary>
public readonly record struct ClockAnchor(long AgentMonoUs, long UtcMs);
@@ -69,6 +69,7 @@ public static class ChunkFinalizer
Phase = source.Phase,
AgentId = agent,
ProbeId = source.ProbeId,
IpVersion = source.IpVersion,
Target = null,
Params = new Dictionary<string, JsonElement>(resolvedParams),
Port = port,
@@ -1,8 +1,8 @@
using System.Text.Json;
using Fleet.Core;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>Agent 一侧为合并保留的按键记录(由正在运行的积木提供)。</summary>
public interface IRecordSource
@@ -29,7 +29,7 @@ public static class FragmentMerger
StreamSpec? streamSpec = spec.FindStream(stream.Name);
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));
@@ -71,7 +71,7 @@ public static class FragmentMerger
RawColumn? key = probe.Find(spec.Key);
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);
@@ -118,7 +118,7 @@ public static class FragmentMerger
{
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 没有报告到达的就是丢失
@@ -3,8 +3,8 @@
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Monitor.Measurement.Tests" />
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../BannedSymbols.Node.txt" />
<ProjectReference Include="..\Monitor.Protocol\Monitor.Protocol.csproj" />
<ProjectReference Include="..\MoShiro.Protocol\MoShiro.Protocol.csproj" />
</ItemGroup>
</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.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>拨号方的一条数据面 TCP 连接(已经完成 TLS 和首帧认证)。</summary>
public sealed class TcpLink : IDisposable
@@ -39,7 +39,7 @@ public sealed class TcpLink : IDisposable
/// </summary>
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
{
long start = env.Clock.NowUs();
@@ -153,6 +153,11 @@ public sealed class TcpAcceptor
foreach (int port in _ports)
{
var listener = new TcpListener(_bind, port);
if (_bind.Equals(IPAddress.IPv6Any))
{
listener.Server.DualMode = true;
}
listener.Start(128);
_listeners.Add(listener);
bound.Add(((IPEndPoint)listener.LocalEndpoint).Port);
@@ -1,8 +1,8 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <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);
@@ -2,9 +2,9 @@ using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using Fleet.Core;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement;
namespace MoShiro.Measurement;
/// <summary>一个通过校验的 UDP 包,交给按 <c>stream_id</c> 登记的处理函数。处理函数在接收循环里同步执行,不能阻塞。</summary>
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]
public enum Scope
@@ -1,4 +1,4 @@
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
/// <summary>
/// Kind 目录。新增一种采集方法 = 在这里加一个静态字段并放进 <see cref="All"/>;
@@ -1,7 +1,7 @@
using System.Globalization;
using System.Text.Json;
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
public enum ParamType
{
@@ -1,7 +1,7 @@
using System.Text.Json.Serialization;
using Fleet.Core;
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
[JsonConverter(typeof(ProbeIdJsonConverter))]
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;
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
/// <summary>Monitor 的错误码(在 Fleet 错误码之上扩展)。REST、JSON-RPC、OpenAPI 共用同一份。</summary>
public static class MonitorErrors
/// <summary>MoShiro 的错误码(在 Fleet 错误码之上扩展)。REST、JSON-RPC、OpenAPI 共用同一份。</summary>
public static class MoShiroErrors
{
public static readonly ErrorCode ValidationFailed = new("validation_failed");
public static readonly ErrorCode BudgetExceeded = new("budget_exceeded");
@@ -1,4 +1,4 @@
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
public static class ProtocolVersion
{
@@ -25,8 +25,8 @@ public static class ProtocolLimits
public const long MaxLineBudgetBitsPerSecond = 100_000;
}
/// <summary>二进制通道号(0–15 属于 Fleet,Monitor 从 16 开始)。</summary>
public static class MonitorChannels
/// <summary>二进制通道号(0–15 属于 Fleet,MoShiro 从 16 开始)。</summary>
public static class MoShiroChannels
{
/// <summary>Agent → Backend:RawChunk(发件箱)。</summary>
public const byte RawChunk = 16;
@@ -35,9 +35,9 @@ public static class MonitorChannels
public const byte ColumnBlock = 17;
}
public static class MonitorCapabilities
public static class MoShiroCapabilities
{
public const string Base = "monitor.v1";
public const string KindsPrefix = "monitor.kinds:";
public const string BlocksPrefix = "monitor.blocks:";
public const string Base = "moshiro.v1";
public const string KindsPrefix = "moshiro.kinds:";
public const string BlocksPrefix = "moshiro.blocks:";
}
@@ -1,4 +1,4 @@
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
/// <summary>CRC-32C(Castagnoli),RawChunk 的完整性校验。</summary>
public static class Crc32c
@@ -2,7 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
/// <summary>Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。</summary>
public sealed class ChunkClock
@@ -58,6 +58,10 @@ public sealed class StreamHeader
/// <summary>块头(JSON,自描述):列定义写在块里,新增列或新增 Kind 不需要升级 container_version。</summary>
public sealed class RawChunkHeader
{
[JsonPropertyName("ip_version")]
[JsonPropertyOrder(1)]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonPropertyName("chunk_id")]
public ChunkId ChunkId { get; set; }
@@ -113,10 +117,10 @@ public sealed class RawChunkHeader
/// <summary>
/// "line:&lt;probe_id&gt;" 或 "target:&lt;task_id&gt;":存储和分析用的序列标识。
/// 一条线路是一对(Probe,Agent):同一个 Agent 上每个 Probe 的数据各是一个序列,互不混合。
/// 同一个 Agent 上每个 Probe 的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。
/// </summary>
[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:";
}
@@ -3,18 +3,18 @@ using System.IO.Compression;
using System.Text.Json;
using Fleet.Core;
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
/// <summary>
/// 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。
/// </summary>
public static class RawChunkCodec
{
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>
public static byte[] EncodeBody(RawChunk chunk)
@@ -40,7 +40,7 @@ public static class RawChunkCodec
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();
Span<byte> length = stackalloc byte[4];
@@ -114,7 +114,7 @@ public static class RawChunkCodec
RawChunkHeader? header;
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)
{
@@ -159,7 +159,7 @@ public static class RawChunkCodec
}
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)
{
@@ -1,4 +1,4 @@
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
/// <summary>按 <see cref="StreamSpec"/> 逐行构造一个 <see cref="RawStream"/>(用于测试、模拟器和离线工具,不用于节点热路径)。</summary>
public sealed class StreamBuilder
@@ -2,25 +2,25 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
/// <summary>monitor.* 方法名。新增方法 = 在这里定义名称和 params/result,再在组装处加一行 <c>.On(...)</c>。</summary>
public static class MonitorMethods
/// <summary>moshiro.* 方法名。新增方法 = 在这里定义名称和 params/result,再在组装处加一行 <c>.On(...)</c>。</summary>
public static class MoShiroMethods
{
public const string TestRun = "monitor.test.run";
public const string TestCancel = "monitor.test.cancel";
public const string ProbeDisconnect = "monitor.probe.disconnect";
public const string TestStatus = "monitor.test.status";
public const string Event = "monitor.event";
public const string Host = "monitor.host";
public const string ProbeAgents = "monitor.probe.agents";
public const string ProbeAgentsChanged = "monitor.probe.agents_changed";
public const string TestRun = "moshiro.test.run";
public const string TestCancel = "moshiro.test.cancel";
public const string ProbeDisconnect = "moshiro.probe.disconnect";
public const string TestStatus = "moshiro.test.status";
public const string Event = "moshiro.event";
public const string Host = "moshiro.host";
public const string UpdateOffer = "moshiro.update.offer";
public const string UpdateApply = "moshiro.update.apply";
// Probe ↔ Agent(点对点会话)
public const string Clock = "monitor.clock";
public const string TestPhase = "monitor.test.phase";
public const string TestPhaseEnd = "monitor.test.phase_end";
public const string TestAbort = "monitor.test.abort";
public const string Clock = "moshiro.clock";
public const string TestPhase = "moshiro.test.phase";
public const string TestPhaseEnd = "moshiro.test.phase_end";
public const string TestAbort = "moshiro.test.abort";
}
public static class AgentEventKinds
@@ -61,6 +61,9 @@ public sealed class TestPhase
public sealed class TestRunParams
{
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonPropertyName("test_id")]
public TestId TestId { get; set; }
@@ -127,6 +130,9 @@ public sealed class AgentEventParams
public sealed class AgentEndpoint
{
[JsonPropertyName("ipv6_host")]
public string? Ipv6Host { get; set; }
[JsonPropertyName("agent_id")]
public NodeId AgentId { get; set; }
@@ -147,15 +153,11 @@ public sealed class AgentEndpoint
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>
[JsonPropertyName("have_version")]
public long HaveVersion { get; set; }
}
public const string StateType = "probe_agents";
public sealed class ProbeAgentsResult
{
[JsonPropertyName("version")]
public long Version { get; set; }
@@ -163,7 +165,7 @@ public sealed class ProbeAgentsResult
public List<AgentEndpoint> Agents { get; set; } = [];
}
/// <summary>注册元数据(Monitor 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
public sealed class AgentEnrollMeta
{
[JsonPropertyName("probe_tcp_port")]
@@ -194,9 +196,121 @@ public sealed class LocalLimits
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>
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")]
public string? TlsSpki { get; set; }
@@ -244,6 +358,10 @@ public sealed class HostSnapshot
[JsonPropertyName("net_tx_bps")]
public long? NetTxBps { get; set; }
/// <summary>已统计接口的内核报告链路速率之和(Mbit/s);驱动不支持或无法读取时为空,不代表互联网带宽。</summary>
[JsonPropertyName("net_link_mbps")]
public long? NetLinkMbps { get; set; }
[JsonPropertyName("uptime_s")]
public long? UptimeSeconds { get; set; }
@@ -254,6 +372,9 @@ public sealed class HostSnapshot
/// <summary>Agent 眼中的一个已连接 Probe。</summary>
public sealed class ConnectedProbe
{
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonPropertyName("id")]
public ProbeId Id { get; set; }
@@ -271,6 +392,9 @@ public sealed class ConnectedProbe
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
public sealed class AgentHeartbeatExtra
{
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
/// <summary>当前连着的 Probe 会话数(等于 <see cref="Probes"/> 的个数)。</summary>
[JsonPropertyName("sessions")]
public int Sessions { get; set; }
@@ -293,6 +417,9 @@ public sealed class AgentHeartbeatExtra
[JsonPropertyName("queue_bytes")]
public long QueueBytes { get; set; }
[JsonPropertyName("update")]
public UpdateStatus? Update { get; set; }
}
/// <summary>Agent 下发给 Probe 的一条线路任务(期望状态类型 <c>line_task_set</c> 里的一项)。</summary>
@@ -334,7 +461,7 @@ public sealed class LineTaskSet
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
{
[JsonPropertyName("probe_mono_us")]
@@ -431,10 +558,15 @@ public static class FragmentKeys
[JsonSerializable(typeof(HostSnapshot))]
[JsonSerializable(typeof(TestStatusParams))]
[JsonSerializable(typeof(AgentEventParams))]
[JsonSerializable(typeof(ProbeAgentsParams))]
[JsonSerializable(typeof(ProbeAgentsResult))]
[JsonSerializable(typeof(ProbeAgentList))]
[JsonSerializable(typeof(UpdateOfferParams))]
[JsonSerializable(typeof(UpdateStatus))]
[JsonSerializable(typeof(ProbeHeartbeatExtra))]
[JsonSerializable(typeof(ProbeHelloMeta))]
[JsonSerializable(typeof(AgentEnrollMeta))]
[JsonSerializable(typeof(PlatformInfo))]
[JsonSerializable(typeof(AgentHelloMeta))]
[JsonSerializable(typeof(Ipv6Diagnostic))]
[JsonSerializable(typeof(AgentHeartbeatExtra))]
[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 Fleet.Core;
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
/// <summary>Task = { id, scope, kind, target, params, schedule, enabled }。参数已经按 ParamSpec 校验并补全默认值。</summary>
public sealed class TaskSpec
@@ -56,9 +56,6 @@ public sealed class TaskSet
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
[JsonPropertyName("peers")]
public List<PeerSpec> Peers { get; set; } = [];
[JsonPropertyName("probe_agents_version")]
public long ProbeAgentsVersion { get; set; }
}
public static class TaskSchedule
@@ -82,9 +79,9 @@ public static class TaskSchedule
{
return minutes >= 60
? 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 Fleet.Core;
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
/// <summary>
/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 Probe 发起;TLS 是可选的(ClientHello 以 0x16 开头,
@@ -1,7 +1,7 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
namespace Monitor.Protocol;
namespace MoShiro.Protocol;
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 Fleet.Core;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Measurement.Tests;
namespace MoShiro.Measurement.Tests;
public sealed class MeasurementTests
{
@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\Monitor.Measurement\Monitor.Measurement.csproj" />
<ProjectReference Include="..\..\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup>
<ItemGroup>
<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 Fleet.Core;
using Monitor.Protocol;
using MoShiro.Protocol;
namespace Monitor.Protocol.Tests;
namespace MoShiro.Protocol.Tests;
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") } }],
Peers = [new PeerSpec { ProbeId = new ProbeId("prb_1"), PublicKey = "AAAA" }],
};
string text = JsonSerializer.Serialize(set, MonitorJsonContext.Default.TaskSet);
TaskSet back = JsonSerializer.Deserialize(text, MonitorJsonContext.Default.TaskSet)!;
string text = JsonSerializer.Serialize(set, MoShiroJsonContext.Default.TaskSet);
TaskSet back = JsonSerializer.Deserialize(text, MoShiroJsonContext.Default.TaskSet)!;
Assert.Contains("\"id\":\"tsk_1\"", text);
Assert.Equal(7, back.Version);
@@ -114,7 +114,7 @@ public sealed class CatalogTests
[Fact]
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(["udp_echo"], meta.Kinds);
}
@@ -122,7 +122,7 @@ public sealed class CatalogTests
[Fact]
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;
Assert.Matches("^[a-z]+(_[a-z]+)*$", value);
Loaded 100 of 380 files, more files were not shown because too many files have changed in this diff. Show more