Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e787a7a5 | ||
|
|
4e7a8c2ba3 | ||
|
|
93abf59afb | ||
|
|
20b0f7fbd8 | ||
|
|
af385cf688 | ||
|
|
9ad34acbac | ||
|
|
d63c3bff74 | ||
|
|
557b169dae | ||
|
|
4a9608ead4 | ||
|
|
fee236fe56 | ||
|
|
32547032d8 | ||
|
|
8efc16f341 | ||
|
|
caee03b8cc | ||
|
|
7ad226f29c | ||
|
|
3a9ea46d6f | ||
|
|
e77880d372 | ||
|
|
bf2a598891 | ||
|
|
758b6b1566 | ||
|
|
f62eefd365 | ||
|
|
70fc033789 | ||
|
|
5822285a88 | ||
|
|
fb3a9c260f | ||
|
|
4c31a4a79e | ||
|
|
e0376b5e33 | ||
|
|
57acecdb86 | ||
|
|
5245f03a78 | ||
|
|
08f261293c | ||
|
|
7fe9775f97 | ||
|
|
35207a70ea | ||
|
|
cfe6571db5 | ||
|
|
9f5a6cf77a | ||
|
|
48ffa56285 | ||
|
|
e0f6667cd7 | ||
|
|
5b1bf09eb0 | ||
|
|
63f62956d0 | ||
|
|
15318de919 | ||
|
|
fae05ff303 | ||
|
|
099c34c715 | ||
|
|
93ecc53cd0 | ||
|
|
b56fd1a3cd | ||
|
|
c0f55275d6 | ||
|
|
334b96324d | ||
|
|
d1ece67a6a | ||
|
|
3654d37da5 | ||
|
|
f2658b24f7 | ||
|
|
bfcac805af | ||
|
|
5d75fe292a | ||
|
|
4f5a4aa3a9 | ||
|
|
13c16ad9c7 | ||
|
|
c6d0c8637e | ||
|
|
59c98e6d7b | ||
|
|
f2af7950e7 | ||
|
|
a867c9f7f6 | ||
|
|
806493567b | ||
|
|
a6f9759238 | ||
|
|
490b008a57 | ||
|
|
4515d7b075 | ||
|
|
c9beeee8e3 | ||
|
|
15f0023614 | ||
|
|
016dbc2bbf | ||
|
|
ad5efc62ff | ||
|
|
d5ebf29e57 | ||
|
|
a9bf849697 | ||
|
|
cc64df3e70 | ||
|
|
a7ebf61551 | ||
|
|
0fd38b2367 | ||
|
|
0066a947da | ||
|
|
cd193378a6 | ||
|
|
24ba05fbb6 | ||
|
|
5c00ec434e | ||
|
|
1ccbb847b5 | ||
|
|
b33e50696d | ||
|
|
fb9dc155f5 | ||
|
|
5594d1cc86 | ||
|
|
e1ec7c9b04 | ||
|
|
d95acc6575 | ||
|
|
c05842fd91 | ||
|
|
09b4630953 | ||
|
|
bbbf0a70bd | ||
|
|
57ae0752aa | ||
|
|
f9be98e801 | ||
|
|
839c45c7d4 | ||
|
|
1a3feddc9d | ||
|
|
68c7113adf | ||
|
|
34190f5a04 | ||
|
|
0d532caf08 | ||
|
|
1145a53004 | ||
|
|
52059b04c1 | ||
|
|
fe049c97fa | ||
|
|
6cf6b23585 | ||
|
|
e82b165eb0 | ||
|
|
f6e477fb10 | ||
|
|
be97e24693 | ||
|
|
b5e275c51f | ||
|
|
b9ec6d80b0 | ||
|
|
a44895d860 | ||
|
|
d7b48c068c | ||
|
|
04f116e092 | ||
|
|
bf3481ebae | ||
|
|
b46ba69aa0 | ||
|
|
2fe8a498d2 | ||
|
|
15f159ab39 | ||
|
|
010576bc2a | ||
|
|
c2ab9eab3b | ||
|
|
a77f12545b | ||
|
|
9919de53d3 | ||
|
|
1975253beb | ||
|
|
2e1dbcf4ce | ||
|
|
a1101424d2 | ||
|
|
0d08e90dab | ||
|
|
047df83a84 | ||
|
|
145660f0a2 | ||
|
|
a1b8af8509 | ||
|
|
a3b9671d62 | ||
|
|
e49e701a73 | ||
|
|
7512ae0f00 | ||
|
|
da353d239a | ||
|
|
49812e94c0 | ||
|
|
376020fe34 | ||
|
|
d9938e4b96 | ||
|
|
8b3abd5b22 | ||
|
|
32507fcb4d | ||
|
|
85c2043f2f | ||
|
|
efb50d0efe | ||
|
|
97ab3cc740 | ||
|
|
238046d959 | ||
|
|
77d8404f59 | ||
|
|
29d998bc94 | ||
|
|
e2e5e437cb | ||
|
|
b25b111134 | ||
|
|
b964dc2bbc | ||
|
|
4609048865 | ||
|
|
900bbde46a | ||
|
|
2d31bd091e | ||
|
|
c9c21f8056 | ||
|
|
b2b63c065d | ||
|
|
0873acf66a | ||
|
|
be04045c8c | ||
|
|
006814823b | ||
|
|
7ff0d0f4e6 | ||
|
|
a9f78f99df | ||
|
|
a3338e1cf3 | ||
|
|
044ce0c339 | ||
|
|
725fe17a7d | ||
|
|
e49990776b | ||
|
|
f8e8bb8acb | ||
|
|
70f20d38d0 | ||
|
|
7ff03a9628 | ||
|
|
5dce387c87 | ||
|
|
535bb3517d | ||
|
|
88b1a0a9bb | ||
|
|
bac1c27e72 | ||
|
|
12be44645f | ||
|
|
e014a19554 | ||
|
|
636a784043 | ||
|
|
0f8f3f9a54 | ||
|
|
904ea2b0ae |
No files matched your search
@@ -1,3 +1,4 @@
|
||||
/.gitattributes text eol=lf
|
||||
ci/build-main-amd64.sh text eol=lf
|
||||
ci/index-backend-image.sh text eol=lf
|
||||
ci/publish-release-offer.sh text eol=lf
|
||||
@@ -7,3 +8,11 @@ packaging/container/*.sh text eol=lf
|
||||
ci/release-node-images.sh text eol=lf
|
||||
packaging/install/*.sh text eol=lf
|
||||
packaging/install/*.ps1 text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.init text eol=lf
|
||||
*.service text eol=lf
|
||||
*.container text eol=lf
|
||||
*.Containerfile text eol=lf
|
||||
*.ps1 text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.md text eol=lf
|
||||
@@ -0,0 +1,46 @@
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
# 要拿到 tag 才算得出开发版本号(ci/version.sh:自最近 tag 的提交数)
|
||||
tags: true
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
||||
|
||||
# 后端镜像:和 dev-image.yml(Agent 镜像)并行,各自的 restore 只还原自己要用的依赖。构建好之后、推送之前由 ci/wait-for-checks.sh 等 verify 和 web-build。
|
||||
# 前端用 web-build workflow 的产物(ci/shared-web.sh)。
|
||||
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
- name: restore-linux-dependencies
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
- python3 ci/guard-step.py restore-backend-dependencies 1800 bash ci/restore-linux-dependencies.sh dev-backend
|
||||
depends_on: []
|
||||
|
||||
- name: build-linux-amd64-backend
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/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
|
||||
commands:
|
||||
- python3 ci/guard-step.py build-linux-amd64-backend 1800 bash ci/build-dev-image.sh backend
|
||||
depends_on:
|
||||
- restore-linux-dependencies
|
||||
@@ -0,0 +1,90 @@
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
# 要拿到 tag 才算得出开发版本号(ci/version.sh:自最近 tag 的提交数)
|
||||
tags: true
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
||||
|
||||
# Agent 镜像:三个 RID 的 Agent 构建,构建完发布。后端镜像在 dev-backend-image.yml 里并行构建:Woodpecker 一个 workflow 里的步骤按依赖深度分层,
|
||||
# 下一层要等上一层全部结束,发布放在后端构建(最慢)后面会白等。
|
||||
# 镜像构建和 verify(后端快速测试)、web-build 并行跑,不再等它们:构建好之后、推送之前由 ci/wait-for-checks.sh 等这两个 workflow 都成功
|
||||
# (任何一个失败就不推;提交信息写 [SKIP TESTS] 只跳过后端测试,verify 照样是成功)。机器有 6 个 workflow 槽(WOODPECKER_MAX_WORKFLOWS),四个同时跑放得下。
|
||||
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
- name: restore-linux-dependencies
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
- python3 ci/guard-step.py restore-linux-dependencies 3600 bash ci/restore-linux-dependencies.sh dev-agent
|
||||
depends_on: []
|
||||
|
||||
- name: build-arm64-agent-musl
|
||||
image: bash
|
||||
environment:
|
||||
GITEA_BASE: http://127.0.0.1:3100
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- python3 ci/guard-step.py build-arm64-agent-musl 3600 bash ci/build-linux-agent.sh linux-musl-arm64
|
||||
depends_on:
|
||||
- restore-linux-dependencies
|
||||
|
||||
- name: build-amd64-agent
|
||||
image: bash
|
||||
environment:
|
||||
GITEA_BASE: http://127.0.0.1:3100
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- python3 ci/guard-step.py build-amd64-agent 3600 bash ci/build-linux-agent.sh linux-x64
|
||||
depends_on:
|
||||
- restore-linux-dependencies
|
||||
|
||||
- name: build-arm64-agent
|
||||
image: bash
|
||||
environment:
|
||||
GITEA_BASE: http://127.0.0.1:3100
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- python3 ci/guard-step.py build-arm64-agent 3600 bash ci/build-linux-agent.sh linux-arm64
|
||||
depends_on:
|
||||
- restore-linux-dependencies
|
||||
|
||||
- name: publish-agent-images
|
||||
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
|
||||
RELEASE_API_URL: https://mo.oeo.one
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- python3 ci/guard-step.py publish-agent-images 900 bash ci/release-node-images.sh
|
||||
depends_on:
|
||||
- build-amd64-agent
|
||||
- build-arm64-agent-musl
|
||||
@@ -0,0 +1,32 @@
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
|
||||
# 带 [CI] 的推送只出镜像,不跑慢测试;这个 workflow 管其余的:PR 和没带 [CI] 的推送
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: main
|
||||
evaluate: 'not (CI_COMMIT_MESSAGE contains "[CI]")'
|
||||
|
||||
# 等 verify 编译完、快的测试过了再跑(共用它的增量编译缓存,不重新编译)
|
||||
depends_on:
|
||||
- verify
|
||||
|
||||
steps:
|
||||
- name: backend-e2e-tests
|
||||
image: bash
|
||||
# [SKIP TESTS] 和 verify 一样,跳过后端测试
|
||||
when:
|
||||
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
# 标了 Category=Slow 的端到端测试,见 ci/verify-backend.sh
|
||||
- python3 ci/guard-step.py backend-e2e-tests 1500 bash ci/verify-backend.sh slow
|
||||
@@ -14,10 +14,11 @@ when:
|
||||
|
||||
depends_on:
|
||||
- linux-release
|
||||
- linux-agent
|
||||
- windows-release
|
||||
|
||||
steps:
|
||||
- name: publish-agent-probe-oci
|
||||
- name: publish-agent-oci
|
||||
image: bash
|
||||
environment:
|
||||
GITEA_BASE: http://127.0.0.1:3100
|
||||
@@ -31,7 +32,7 @@ steps:
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/release-node-images.sh
|
||||
- python3 ci/guard-step.py publish-agent-oci 900 bash ci/release-node-images.sh
|
||||
depends_on: []
|
||||
|
||||
- name: publish-gitea-release
|
||||
@@ -43,6 +44,6 @@ steps:
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/publish-gitea-release.sh
|
||||
- python3 ci/guard-step.py publish-gitea-release 1800 bash ci/publish-gitea-release.sh
|
||||
depends_on:
|
||||
- publish-agent-probe-oci
|
||||
- publish-agent-oci
|
||||
@@ -0,0 +1,64 @@
|
||||
labels:
|
||||
ci: domain-linux-native
|
||||
platform: linux/amd64
|
||||
|
||||
clone:
|
||||
- name: git
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
tags: true
|
||||
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/v*
|
||||
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
- name: restore-linux-dependencies
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
- python3 ci/guard-step.py restore-linux-dependencies 3600 bash ci/restore-linux-dependencies.sh agent
|
||||
depends_on: []
|
||||
|
||||
- name: build-arm64-agent-musl
|
||||
image: bash
|
||||
environment:
|
||||
GITEA_BASE: http://127.0.0.1:3100
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- python3 ci/guard-step.py build-arm64-agent-musl 3600 bash ci/build-linux-agent.sh linux-musl-arm64
|
||||
depends_on:
|
||||
- restore-linux-dependencies
|
||||
|
||||
- name: build-amd64-agent
|
||||
image: bash
|
||||
environment:
|
||||
GITEA_BASE: http://127.0.0.1:3100
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- python3 ci/guard-step.py build-amd64-agent 3600 bash ci/build-linux-agent.sh linux-x64
|
||||
depends_on:
|
||||
- restore-linux-dependencies
|
||||
|
||||
- name: build-arm64-agent
|
||||
image: bash
|
||||
environment:
|
||||
GITEA_BASE: http://127.0.0.1:3100
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- python3 ci/guard-step.py build-arm64-agent 3600 bash ci/build-linux-agent.sh linux-arm64
|
||||
depends_on:
|
||||
- restore-linux-dependencies
|
||||
@@ -22,35 +22,44 @@ steps:
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /home/justme/.npm
|
||||
commands:
|
||||
- npm ci --prefix web --prefer-offline
|
||||
- timeout 600 npm run build --prefix web
|
||||
# 版本取自 tag:先写进工作区(仓库里的版本号只是占位),前端才会带上对的版本
|
||||
- python3 ci/guard-step.py web-build 900 bash ci/build-release-web.sh
|
||||
depends_on: []
|
||||
|
||||
- name: restore-linux-dependencies
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
- python3 ci/guard-step.py restore-linux-dependencies 1800 bash ci/restore-linux-dependencies.sh release
|
||||
depends_on:
|
||||
- web-build
|
||||
|
||||
- name: linux-amd64-backend
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
commands:
|
||||
- bash ci/release-linux.sh amd64 backend
|
||||
- python3 ci/guard-step.py linux-amd64-backend 1800 bash ci/release-linux.sh amd64 backend
|
||||
depends_on:
|
||||
- web-build
|
||||
- restore-linux-dependencies
|
||||
|
||||
- name: linux-arm64-backend
|
||||
image: bash
|
||||
environment:
|
||||
NUGET_PACKAGES: /home/justme/.nuget/packages
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
ZOT_REGISTRY: registry.oeo.one
|
||||
ZOT_USERNAME: justme
|
||||
ZOT_SECRET:
|
||||
from_secret: zot_secret
|
||||
commands:
|
||||
- bash ci/release-linux.sh arm64 backend
|
||||
- python3 ci/guard-step.py linux-arm64-backend 1800 bash ci/release-linux.sh arm64 backend
|
||||
depends_on:
|
||||
- web-build
|
||||
- restore-linux-dependencies
|
||||
|
||||
- name: backend-multiarch-index
|
||||
image: bash
|
||||
@@ -64,7 +73,7 @@ steps:
|
||||
RELEASE_SIGNING_KEY:
|
||||
from_secret: release_signing_key
|
||||
commands:
|
||||
- bash ci/index-backend-image.sh
|
||||
- python3 ci/guard-step.py backend-multiarch-index 900 bash ci/index-backend-image.sh
|
||||
depends_on:
|
||||
- linux-amd64-backend
|
||||
- linux-arm64-backend
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
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
|
||||
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
|
||||
commands:
|
||||
- bash ci/build-main-amd64.sh probe
|
||||
depends_on: []
|
||||
@@ -11,16 +11,21 @@ clone:
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: dev
|
||||
branch: main
|
||||
|
||||
# On Gitea PR events, CI_COMMIT_MESSAGE is the PR title.
|
||||
steps:
|
||||
- name: openwrt-updater-tests
|
||||
image: bash
|
||||
commands:
|
||||
- python3 ci/guard-step.py openwrt-updater-tests 600 bash ci/test-ci-scripts.sh
|
||||
- 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
|
||||
NUGET_PACKAGES: /home/justme/woodpecker/cache/nuget/packages
|
||||
commands:
|
||||
- cd backend && dotnet test --solution MoShiro.slnx -c Release
|
||||
# 增量编译 + 快的那批测试(约 20 秒);慢的端到端测试在 e2e workflow 里和出镜像并行跑。见 ci/verify-backend.sh
|
||||
- python3 ci/guard-step.py backend-tests 900 bash ci/verify-backend.sh fast
|
||||
@@ -7,21 +7,26 @@ clone:
|
||||
image: plugin-git
|
||||
settings:
|
||||
lfs: false
|
||||
# [CI] 构建要算开发版本号(ci/version.sh:自最近 tag 的提交数),要拿到 tag
|
||||
tags: true
|
||||
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: dev
|
||||
|
||||
depends_on:
|
||||
- verify
|
||||
branch: main
|
||||
|
||||
# 和 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
|
||||
# node_modules 按锁文件哈希缓存,展示端和管理端并行构建,再检查 CSS,见 ci/web-build.sh
|
||||
# main 上带 [CI] 的提交:先写入开发版本号,构建完把产物交给同一条流水线的开发镜像(ci/shared-web.sh),前端只构建一次
|
||||
- |
|
||||
if [ "$CI_PIPELINE_EVENT" = push ] && printf '%s' "$CI_COMMIT_MESSAGE" | grep -qF '[CI]'; then
|
||||
export WEB_PUBLISH_SHARED=1
|
||||
bash ci/version.sh apply
|
||||
fi
|
||||
python3 ci/guard-step.py web-build 900 bash ci/web-build.sh
|
||||
@@ -15,128 +15,24 @@ when:
|
||||
concurrency: 1
|
||||
|
||||
steps:
|
||||
- name: build-linux-x64-agent
|
||||
- name: build-macos-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
|
||||
RID: osx-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
|
||||
<<: *build-environment
|
||||
COMPONENT: fleet-enroll
|
||||
commands: *build-commands
|
||||
depends_on: []
|
||||
@@ -149,14 +45,6 @@ steps:
|
||||
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
|
||||
@@ -173,14 +61,6 @@ steps:
|
||||
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
|
||||
@@ -197,14 +77,6 @@ steps:
|
||||
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
|
||||
@@ -220,27 +92,11 @@ steps:
|
||||
- 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
|
||||
@@ -1,43 +1,89 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Project
|
||||
MoShiro is a self-hosted network path quality monitor. Agents on home broadband, NAT networks, or public VPS hosts run active measurements and record raw per-packet and per-event data. The Backend stores it, derives metrics, scores lines per service model, and serves a REST/SSE API to two web apps. Documentation is in Chinese under [`docs/`](docs/); source comments are mostly Chinese too. [`README.md`](README.md) is the entry point.
|
||||
|
||||
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.
|
||||
## Mental model
|
||||
|
||||
## Branch and workspace safety
|
||||
| Term | Meaning |
|
||||
| --- | --- |
|
||||
| Agent | Unified collector process (NativeAOT, BCL only), listening and dialing by default. Each Agent connects to the Backend over `wss` and reports outgoing link health and incoming sessions. |
|
||||
| Line | One directed (dialer, listener) Agent pair, per IP family. Id `<agent_id>~<dialer_id>`, IPv6 adds `~ipv6`; series key `line:<dialer_id>` |
|
||||
| Kind | A declared measurement method (`udp_echo`, `tcp_echo`, `connect`, ...) composed from building blocks. Defined once in `MoShiro.Protocol/Catalog/Kinds` |
|
||||
| RawChunk | Self-describing columnar block, cut every 10 s, appended to segment files. The Backend never trusts node-reported statistics |
|
||||
| Metric, Model | `MetricDef` is a one-line declaration over accumulators; models score metrics at query time. Both are recomputable from raw data |
|
||||
| Plan | A sequence of on-demand test phases (for example `hy2_ladder`). Exists only in the Backend (`Application/Tests/Plans.cs`) |
|
||||
| Fleet | Product-agnostic node framework under `backend/fleet/` (identity, enrollment, RPC, link, outbox, state sync) |
|
||||
| Operator set | Passkey-signed trust root. Nodes pin its genesis fingerprint and owner at install time and verify the chain themselves |
|
||||
|
||||
- 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). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
|
||||
- Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`.
|
||||
## Invariants (violations fail the build or review)
|
||||
|
||||
## Project layout
|
||||
- Node processes are kept to measurement and reporting by build-time restrictions: process start, dynamic loading, `HttpClient`, and file writes outside `Fleet.Hosting.ConfigStore` are banned (`backend/BannedSymbols.Node.txt`). Capabilities such as updates or command execution are not part of the core; adding or changing one needs a design review against `docs/security/security.md`.
|
||||
- `backend/fleet/**` must not reference `MoShiro.*` (`ArchitectureTests`). `MoShiro.Measurement` contains no service concepts such as REALITY or Hysteria2.
|
||||
- Every RPC method is declared in a method table (`FleetMethodTable`, `MoShiroMethodTable`) with direction, type and roles. Add the declaration before the handler.
|
||||
- `Backend.Api` must not reference `Storage`. Error-code to HTTP mapping happens once, in the Api layer.
|
||||
- Server-sent parameters pass `ParamSpec` validation and then the node-local hard limits (`BudgetGuard`). Never bypass either.
|
||||
- Public API responses contain no IP, city or ASN.
|
||||
- Code style is enforced at build time (`.editorconfig`, `BannedSymbols.txt`, warnings as errors). Details in [`docs/dev/coding-style.md`](docs/dev/coding-style.md).
|
||||
|
||||
- `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.
|
||||
## Where to change what
|
||||
|
||||
## Local development and builds
|
||||
| Task | Location |
|
||||
| --- | --- |
|
||||
| New measurement method | Kind declaration in `backend/shared/MoShiro.Protocol/Catalog/Kinds`; new building block only if needed in `MoShiro.Measurement/Blocks` plus `BlockCatalog` |
|
||||
| New metric | `MetricDef` in `backend/src/MoShiro.Backend.Analysis/Metrics`, add to `MetricRegistry.All` and a preset if shown |
|
||||
| New service model | `Analysis/Models` |
|
||||
| New RPC method | `MoShiro.Protocol/Rpc` (params, `MoShiroMethodTable`), then `router.On(...)` in `AgentApp`; Backend-side handlers register in `Backend.Gateway` (`FleetGateway`) |
|
||||
| New REST endpoint | `Backend.Api/Public` or `Admin` (typed results, operationId, capability), contract DTO in `Api/Contracts`, then regenerate `backend/openapi/v1.json` and web types |
|
||||
| New capability | `Capability` constants in `Backend.Application/Auth/Capabilities.cs`; every `/api/admin` endpoint must declare one |
|
||||
| Schema change | Append a migration in `Storage.Sqlite/Schema.cs` and bump `CurrentVersion` |
|
||||
| Agent-to-Agent connection states, retry cadence, health reporting | `backend/agent/src/MoShiro.Agent/Dial` (`DialConnection` control layer, `TaskLink` QUIC channel, `LinkStatus`), `LinkHealth` in `MoShiro.Protocol/Rpc`, [`docs/design/architecture.md`](docs/design/architecture.md) §4.3 |
|
||||
| Install or update behaviour | `packaging/` (install scripts, Quadlet, compose, updater) and `docs/ops/deployment.md` |
|
||||
| Web UI | `web/display` (anonymous read-only), `web/admin`, shared code in `web/shared`. Colors only in `web/shared/theme/tokens.css` |
|
||||
|
||||
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`.
|
||||
## Documentation
|
||||
|
||||
- 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.
|
||||
Index: [`README.md`](README.md#文档). Prefer the code and `backend/openapi/v1.json` when they disagree with a document, and fix the document. Update docs in the same change as behaviour: `docs/design` (architecture, protocol, measurement, analysis, storage, query), `docs/security`, `docs/api`, `docs/ops`, `docs/dev`, and `docs/roadmap.md` for unimplemented work. Do not record dates or change history in docs.
|
||||
|
||||
Follow the user's requested validation scope. Do not start a full tag/release build unless requested.
|
||||
Writing style for docs, READMEs, comments and commit messages (the `stop-slop` skill lists the patterns in detail; it is written for English, the same rules apply to Chinese):
|
||||
|
||||
- State the fact directly. Drop "not X but Y" contrasts, negation lists, rhetorical setups, and aphorisms that read like pull-quotes.
|
||||
- Name the actor. Prefer "the Agent rejects the task" over "the task is rejected".
|
||||
- No filler openers ("it is worth noting"), no closing summaries, no adverbs doing emphasis. Avoid bold for emphasis.
|
||||
- Do not claim absolutes ("never", "always", "cannot") unless the code or a build-time check enforces it. Describe current behaviour and mark planned work as planned.
|
||||
- Give concrete names, paths, values and commands instead of general statements.
|
||||
|
||||
## Workspace safety
|
||||
|
||||
- Inspect `git status` before editing. Preserve existing uncommitted changes; do not reset, clean, stash, or commit unrelated files.
|
||||
- `main` is the only working branch. Commit to it directly; use a pull request when review is required.
|
||||
- Never commit credentials, API keys, registry passwords, or generated runtime data. CI credentials are Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key`).
|
||||
- The release signing private key must not enter the repository or the panel. Only the public key under `packaging/update/` is versioned.
|
||||
|
||||
## Build and test
|
||||
|
||||
Requirements: .NET SDK 10 (pinned by `global.json`) and Node.js with npm. NuGet versions are centralized in `Directory.Packages.props`.
|
||||
|
||||
| Task | Command |
|
||||
| --- | --- |
|
||||
| Build all .NET projects | `dotnet build MoShiro.slnx -c Release` |
|
||||
| Backend tests | `cd backend && dotnet test --solution MoShiro.slnx -c Release` |
|
||||
| Run Backend | `cd backend && dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000` |
|
||||
| Web dependencies and build | `npm ci --prefix web && npm run build --prefix web` |
|
||||
| Web dev servers | `npm run dev --prefix web` (ports are set in the Vite configs) |
|
||||
| Style check | `npm run lint:css --prefix web` |
|
||||
| Regenerate API types | `npm run gen:api --prefix web` |
|
||||
|
||||
First run of the Backend prints a claim code in its log; the admin UI asks for it. Slow end-to-end tests carry `[Trait("Category", "Slow")]`. After changing API contracts, update `backend/openapi/v1.json` and regenerate the web types. Run only the validation the task requires; do not start a full tag or 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.
|
||||
Triggers, tags and image paths are defined in `.woodpecker/*.yml` and [`docs/ops/deployment.md`](docs/ops/deployment.md). Verify them there before changing or describing them. The rules below are the stable contract.
|
||||
|
||||
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.
|
||||
- Every push to `main` runs the fast Backend tests and the web build. Both must pass before images are published.
|
||||
- A commit message containing `[CI]` builds and pushes development images. `[CI SKIP]` skips the pipeline; `[SKIP TESTS]` skips only the Backend tests. Slow end-to-end tests run only for pushes without `[CI]` and for pull requests.
|
||||
- A tag `vMAJOR.MINOR.PATCH` runs the full cross-platform release pipeline. No version-bump commit is needed.
|
||||
- Versions are produced by `ci/version.sh`. The `<Version>` in `backend/Directory.Build.props` and the `version` in `web` `package.json` files are placeholders (`0.0.0-dev`) and must not be edited.
|
||||
- Linux Agent builds use `ci/build-linux-agent.sh` for glibc on amd64/arm64 and musl on arm64 in both dev and tag workflows. OCI and OpenWrt installers reuse these native outputs; Windows runners build only Windows/macOS.
|
||||
- Build stores keep NuGet cache mounts, retain image layers for 7 days, and clean temporary outputs under store locks (shared for native builds, exclusive for cleanup). Do not use `buildah prune` on these stores: it removes restore caches too.
|
||||
- Development images use the `moshiro-dev/` repository path and release images use `moshiro/`. A development build must never overwrite a release tag such as `latest`.
|
||||
- Release manifests are registered at `/api/release/offer`, which requires no token because the panel verifies the signature.
|
||||
+2
-5
@@ -27,11 +27,8 @@
|
||||
<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 Name="/backend/agent/tests/">
|
||||
<Project Path="backend/agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/backend/src/">
|
||||
<Project Path="backend/src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
|
||||
|
||||
@@ -1,118 +1,70 @@
|
||||
# MoShiro
|
||||
|
||||
MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 Probe(例如家宽出口)与多台 Agent(例如海外 VPS)之间做主动网络测量,只记录逐包、逐事件的原始数据;Backend 保存这些原始数据,推算延迟、丢包、抖动、吞吐等指标,并回答「如果在这条线路上部署某类服务,体验会怎样」;Web 界面用来查看、比较和管理。
|
||||
MoShiro 旨在解决跨网络线路质量难以量化的问题:
|
||||
|
||||
## 项目边界
|
||||
- 家宽到海外 VPS 的延迟、丢包、抖动随时段波动,单点 ping 无法反映真实体验;
|
||||
- 不同代理、游戏、流媒体服务对线路的敏感度各异,缺少统一的评价口径。
|
||||
|
||||
**它做什么**
|
||||
它通过 Agent 之间的主动测量采集原始数据,由 Backend 推算指标,并按服务模型(REALITY、Hysteria2、Minecraft 等)对线路评分与排序。
|
||||
|
||||
- 主动测量:组合 TCP、UDP、TLS、ICMP 等采集积木,只记录时间、序号、大小这类原始事实,**不转发也不记录业务内容**。
|
||||
- 线路分析:Backend 从原始数据生成时间序列指标,按服务模型(REALITY、Hysteria2、Minecraft 等)评分并对多条线路排序、对比;模型和指标可以事后重算。
|
||||
- 管理:节点注册与审批、Probe 和 Agent 之间连哪些线路、任务与按需测试、节点版本与更新,都在管理端完成。
|
||||
## 组件
|
||||
|
||||
**它不做什么**
|
||||
|
||||
- **节点是纯采集器**:Agent 和 Probe 没有终端、命令执行、文件读写和自更新能力,也不加载外部代码。更新由节点宿主机上的更新器完成,节点进程只放面板转来的发布清单。
|
||||
- **不是主机监控**:Agent 只上报 CPU、内存、磁盘、网卡流量和运行时长这类只读的汇总数字,不采集型号、主机名、进程和任何 IP;Probe 不上报主机资源。
|
||||
- **不暴露地址**:公开接口里没有 IP、城市、ASN;国家和运营商只由 Backend 查询(节点自己不访问任何外部服务)。
|
||||
- 暂不包含告警(见文末待办)。
|
||||
|
||||
## 信任关系
|
||||
| 组件 | 职责 |
|
||||
| --- | --- |
|
||||
| Agent | 统一采集节点,默认同时监听和拨号;可部署在 VPS、家宽或 NAT 后 |
|
||||
| Backend | 注册、线路编排、存储、分析和 REST / SSE |
|
||||
| Web | 匿名只读展示端 `display` 与管理端 `admin` |
|
||||
|
||||
```text
|
||||
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
|
||||
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
|
||||
|
||||
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
|
||||
Probe ── 用同一把密钥签名建立测量会话 ───────────────────────────────────▶ Agent(只放行面板名单里的 Probe 公钥)
|
||||
CI ── 发布私钥对「镜像 digest 清单」签名 ──▶ Backend 原样转发 ──▶ 节点宿主机的更新器用本机公钥验签
|
||||
Backend ---- REST / SSE ---- Web
|
||||
^ ^
|
||||
| | 每个 Agent 独立建立 wss,汇报自身及线路状态
|
||||
| |
|
||||
Agent A <=====> Agent B
|
||||
直连测量
|
||||
```
|
||||
|
||||
- **Backend 不信任节点上报的内容**:数据按线路隔离存放;节点的身份靠签名,数据库泄露也无法冒充节点。
|
||||
- **节点不信任 Backend 的下发**:任务超出节点本机的硬上限(目标网段、码率、包速率、监听端口)就被拒绝;节点不执行任何「执行类」指令。
|
||||
- **更新不依赖面板的诚实**:清单必须由发布私钥签名;节点宿主机的更新器用本机公钥验签、只认本机配置的镜像仓库、按 digest 拉取、拒绝旧的 `seq`。面板被攻破也推不出恶意镜像。
|
||||
Agent 默认向其他可拨号 Agent 建立测量线路,A→B 和 B→A 分别记录;节点不连接自身。
|
||||
管理端统一展示 Agent,在线路画板中按方向开关测量、查看连接状态和立即重试。
|
||||
家宽或 NAT 后的 Agent 仍可拨出;入站测量需要其他节点能够访问其监听地址和端口。
|
||||
可在本地 `agent.json` 中显式设置 `listen.enabled: false` 关闭监听,节点身份和程序保持统一。
|
||||
|
||||
## 架构
|
||||
## 快速开始
|
||||
|
||||
```text
|
||||
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 决定:默认自动全连,也可以在管理端手动选择。
|
||||
|
||||
```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。在仓库根目录:
|
||||
环境:.NET SDK 10(版本见 `global.json`)、Node.js 与 npm。
|
||||
|
||||
```sh
|
||||
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
|
||||
cd backend && dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000 # Backend
|
||||
npm ci --prefix web && npm run dev --prefix web # Web(另开终端,仓库根目录)
|
||||
```
|
||||
|
||||
本地运行:先在 `backend/` 里 `dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000`,再 `npm run dev --prefix web`,展示端是 <http://localhost:5173/>,管理端是 <http://localhost:5173/admin/>。
|
||||
展示端 <http://localhost:5173/>,管理端 <http://localhost:5173/admin/>。首次访问管理端进入认领向导,认领码输出在后端日志。
|
||||
|
||||
测试和检查:
|
||||
构建与测试:
|
||||
|
||||
```sh
|
||||
dotnet test --solution MoShiro.slnx # 后端和节点的测试
|
||||
npm run lint:css --prefix web # 前端样式(颜色只能写在主题令牌里)
|
||||
npm run gen:api --prefix web # 后端改了接口之后重新生成前端类型
|
||||
dotnet build MoShiro.slnx -c Release
|
||||
dotnet test --solution MoShiro.slnx
|
||||
npm run build --prefix web
|
||||
```
|
||||
|
||||
Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent 和 Probe 另有 Linux glibc/musl、macOS、Windows 的二进制包。NativeAOT 的构建细节、CI 触发规则(`[CI]`、`[SKIP TESTS]`、版本 tag)、镜像路径、容器节点的运行和更新见 [`docs/deployment.md`](docs/deployment.md);前端见 [`web/README.md`](web/README.md),后端见 [`backend/README.md`](backend/README.md)。
|
||||
|
||||
## 文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
| 主题 | 文档 |
|
||||
| --- | --- |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
|
||||
| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 |
|
||||
| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 |
|
||||
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
|
||||
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
|
||||
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
|
||||
| [`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) | 仓库结构与依赖规则、代码风格 |
|
||||
| 架构与数据链路 | [architecture](docs/design/architecture.md) |
|
||||
| 线协议与方法 | [protocol](docs/design/protocol.md) |
|
||||
| 采集引擎与任务 | [measurement](docs/design/measurement.md) |
|
||||
| 分析、存储、查询 | [analysis](docs/design/analysis.md) · [storage](docs/design/storage.md) · [query](docs/design/query.md) |
|
||||
| 安全:威胁模型、访问控制、注册与身份 | [security](docs/security/security.md) · [access](docs/security/access.md) · [enrollment](docs/security/enrollment.md) |
|
||||
| REST / SSE 接口 | [api](docs/api/api.md) |
|
||||
| 构建、发布、CI、部署与更新 | [deployment](docs/ops/deployment.md) |
|
||||
| 仓库结构与代码风格 | [project-layout](docs/dev/project-layout.md) · [coding-style](docs/dev/coding-style.md) |
|
||||
| 路线图 | [roadmap](docs/roadmap.md) |
|
||||
| 子项目 | [backend](backend/README.md) · [fleet](backend/fleet/README.md) · [agent](backend/agent/README.md) · [web](web/README.md) |
|
||||
|
||||
代码目录:`backend/`(Backend、`fleet/` 节点框架、`shared/` 测量引擎和协议、`agent/`、`probe/`、`tests/`)、`web/`(`display` 展示端、`admin` 管理端、`shared`)、`packaging/` 和 `scripts/`(部署与维护)、`ci/` 和 `.woodpecker/`(流水线)。
|
||||
|
||||
## 待办
|
||||
|
||||
见 [`TODO.md`](TODO.md)。
|
||||
贡献与自动化代理的工作约定见 [`AGENTS.md`](AGENTS.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [Apache License 2.0](LICENSE)。
|
||||
[Apache License 2.0](LICENSE)
|
||||
@@ -1,48 +0,0 @@
|
||||
# TODO
|
||||
|
||||
按主题记录还没做的事,做完就删掉对应的条目。已经实现的东西以 [`docs/`](docs/) 和代码为准。
|
||||
|
||||
## 测量
|
||||
|
||||
- 路由跳数、路径漂移和 trace
|
||||
- QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测
|
||||
- 网页长截图
|
||||
- pcap / eBPF 采集
|
||||
|
||||
## 任务与面板
|
||||
|
||||
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
|
||||
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security.md`](docs/security.md) 第 9.2 节
|
||||
|
||||
## 部署
|
||||
|
||||
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
|
||||
[security.md](docs/security.md)
|
||||
## 访客页面
|
||||
|
||||
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 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)。
|
||||
|
||||
## 安全
|
||||
|
||||
- **零信任**:设计稿在 [`docs/security.md`](docs/security.md),按其中第 12 节的顺序实现(阶段零「协议方向声明表」已完成):Probe 自己生成密钥、Agent 预握手敲门、操作员凭证和名册、测量数据的签名和归属校验、入库配额和管理端 CSP、Backend 应用层身份、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT);可选的 UDP 单包授权、UDP 会话迁移的路径验证。
|
||||
- **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。
|
||||
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
|
||||
|
||||
## 打包
|
||||
|
||||
- OpenWrt:procd 脚本、ipk/apk 包、sysupgrade 保留列表,在 aarch64 路由器上长时间运行验证。
|
||||
- Windows 服务适配。
|
||||
@@ -13,11 +13,17 @@
|
||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
||||
<Deterministic>true</Deterministic>
|
||||
<Version>0.3.3</Version>
|
||||
<Version>0.0.0-dev</Version>
|
||||
<NuGetAudit>false</NuGetAudit>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(UseAotAnywhere)' == 'true' and $([MSBuild]::IsOSPlatform('Linux'))">
|
||||
<PackageReference Include="Vezel.Zig.Toolsets.linux-x64"
|
||||
VersionOverride="0.16.0.2"
|
||||
PrivateAssets="all"
|
||||
GeneratePathProperty="true" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -27,11 +27,8 @@
|
||||
<Folder Name="/agent/src/">
|
||||
<Project Path="agent/src/MoShiro.Agent/MoShiro.Agent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/probe/src/">
|
||||
<Project Path="probe/src/MoShiro.Probe/MoShiro.Probe.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/probe/tests/">
|
||||
<Project Path="probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj" />
|
||||
<Folder Name="/agent/tests/">
|
||||
<Project Path="agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/backend/src/">
|
||||
<Project Path="src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
|
||||
|
||||
+4
-4
@@ -36,9 +36,9 @@ dotnet test --solution MoShiro.slnx
|
||||
dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true
|
||||
```
|
||||
|
||||
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/deployment.md`](../docs/deployment.md)。
|
||||
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。
|
||||
|
||||
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 与 Probe 的多平台二进制包单独发布,不包含在 Backend 镜像中。
|
||||
正式发布通过 Woodpecker tag 流水线生成 Linux/amd64 与 Linux/arm64 OCI 镜像,容器内监听 `8080`,数据写入挂载的 `/data` 卷。Agent 的多平台二进制包单独发布,不包含在 Backend 镜像中;Linux runner 构建三个 Linux RID,Windows runner 构建 macOS 和 Windows。
|
||||
|
||||
## 本地运行
|
||||
|
||||
@@ -49,6 +49,6 @@ cd backend
|
||||
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
|
||||
```
|
||||
|
||||
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
||||
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/ops/deployment.md`](../docs/ops/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
|
||||
|
||||
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
|
||||
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api/api.md`](../docs/api/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
|
||||
@@ -8,7 +8,7 @@
|
||||
<EventSourceSupport>false</EventSourceSupport>
|
||||
<DebuggerSupport>false</DebuggerSupport>
|
||||
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
||||
<Http3Support>false</Http3Support>
|
||||
<Http3Support>true</Http3Support>
|
||||
<MetricsSupport>false</MetricsSupport>
|
||||
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
|
||||
+16
-14
@@ -1,20 +1,22 @@
|
||||
# MoShiro Agent
|
||||
|
||||
Agent 是部署在 VPS 或其他可达网络节点上的采集进程。它向 Backend 注册并保持连接,接受 Probe 的测量会话,执行线路任务中的 Agent 侧工作,再将两端数据合并后上报。
|
||||
|
||||
Agent 仅开放配置的控制和数据端口。它不提供远程终端、命令执行或文件管理;本地配置中的网络范围、速率和并发限制由节点自行执行,Backend 下发的任务不能越过这些限制。
|
||||
|
||||
## 工作流程
|
||||
Agent 是统一的采集节点,默认同时监听和拨号。每个 Agent 独立连接 Backend,接收任务集和拨号目标;节点之间通过 TLS 控制会话、QUIC Datagram 和 TCP 数据连接完成测量。
|
||||
|
||||
```text
|
||||
Probe ──TLS 控制 / UDP、TCP 测量──> Agent ──长连接与数据上报──> Backend
|
||||
▲
|
||||
fleet-enroll 注册
|
||||
Agent A <==== 直接测量 ====> Agent B
|
||||
| |
|
||||
+--------- wss -------------+
|
||||
|
|
||||
Backend
|
||||
```
|
||||
|
||||
Backend 默认安排 Agent 之间的双向有向线路,自连接被排除。拨号方通过心跳上报控制层、TCP 和 QUIC 状态、失败原因及重试时间;监听方上报接入会话。Backend 汇总这些报告,供 Agent 详情和线路矩阵使用。测量数据由监听方合并后上传。
|
||||
|
||||
本地 `listen.enabled` 默认 `true`。可显式关闭监听,适用于无需入站测量的节点;这仍是同一种 Agent。NAT 后的节点可主动拨出,入站可达性取决于地址和端口映射。
|
||||
|
||||
## 构建
|
||||
|
||||
需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建:
|
||||
需要 .NET SDK 10(版本见根目录 `global.json`)。从 `backend/` 目录构建:
|
||||
|
||||
```sh
|
||||
cd backend
|
||||
@@ -29,7 +31,7 @@ dotnet publish agent/src/MoShiro.Agent -c Release -r <RID> -p:PublishAot=true
|
||||
dotnet publish fleet/tools/fleet-enroll -c Release -r <RID> -p:PublishAot=true
|
||||
```
|
||||
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/deployment.md`](../../docs/deployment.md)。
|
||||
也可在仓库根目录运行 `bash scripts/publish.sh agent <RID>` 和 `bash scripts/publish.sh fleet-enroll <RID>`。部署包和平台说明见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
|
||||
|
||||
## 注册与运行
|
||||
|
||||
@@ -47,7 +49,7 @@ fleet-enroll --server https://<backend> --join <token> --name <agent-name> --ide
|
||||
moshiro-agent --config /etc/moshiro/agent.json
|
||||
```
|
||||
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。也可使用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-agent:<version>`,自动选择 Linux amd64 或 arm64 镜像。镜像同时包含 `fleet-enroll`;可通过 `--entrypoint /usr/local/bin/fleet-enroll` 执行首次注册。容器默认读取命名 volume 中的 `/etc/moshiro/agent.json` 与身份文件;建议使用宿主网络,Linux ICMP 优先使用普通用户 ping socket,内核 ping_group_range 需允许映射后的 GID。镜像以 UID/GID 1654 运行,新命名 volume 由引擎按镜像目录初始化权限。
|
||||
|
||||
不传 `--config` 时默认读取 `/etc/moshiro/agent.json`。Agent 默认监听控制端口 `7443/tcp`,数据端口 `7443/udp` 和 `7444/tcp`;防火墙和面板中的节点端口配置需与本地配置一致。`reporting.host_stats` 可关闭主机状态上报;启用时只上报资源使用等汇总数据,不包含主机名、进程或 IP。
|
||||
|
||||
@@ -55,9 +57,9 @@ IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为
|
||||
|
||||
## 设计说明
|
||||
|
||||
- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。
|
||||
- 每个 拨号方 使用独立的 TLS 会话和数据密钥;拨号方 断开时对应的会话资源会释放。
|
||||
- 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。
|
||||
- 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。
|
||||
- 详细安全边界与拓扑见 [`../../docs/architecture.md`](../../docs/architecture.md),协议说明见 [`../../docs/protocol.md`](../../docs/protocol.md),部署见 [`../../docs/deployment.md`](../../docs/deployment.md)。
|
||||
- 详细安全边界与拓扑见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md),协议说明见 [`../../docs/design/protocol.md`](../../docs/design/protocol.md),部署见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。
|
||||
|
||||
Docker 与 rootless/rootful Podman 共用 `packaging/compose/agent/compose.yaml`,配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
Podman 系用 Quadlet(`packaging/quadlet/moshiro-agent.container`,默认自动更新),Docker 用 `packaging/compose/agent/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。
|
||||
@@ -10,13 +10,17 @@ using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
|
||||
public sealed class AgentApp : IDisposable
|
||||
/// <summary>
|
||||
/// 节点的组装点:与 Backend 的链路、监听侧(数据面和点对点监听,<c>listen.enabled</c> 为 false 时不启动)、拨号侧(Backend 下发的拨号目标)都在这里接起来。
|
||||
/// 没有全局状态,测试可以在同一进程里启动多个。
|
||||
/// </summary>
|
||||
public sealed class AgentApp : IDisposable, IAsyncDisposable
|
||||
{
|
||||
public const string Version = MoShiro.Protocol.BuildInfo.Version;
|
||||
|
||||
private readonly AgentConfig _config;
|
||||
private readonly bool _ipv6Supported;
|
||||
private readonly bool _listening;
|
||||
private readonly bool? _ipv6Supported;
|
||||
private readonly KnockGate _knock;
|
||||
private readonly TrustAnchor _trust;
|
||||
private readonly RestartController _restart;
|
||||
@@ -24,6 +28,7 @@ public sealed class AgentApp : IDisposable
|
||||
private readonly LoadedNodeIdentity _identity;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
private readonly INodeLogSource? _logSource;
|
||||
private readonly MonoClock _clock;
|
||||
private readonly AgentCounters _counters = new();
|
||||
private readonly SessionRegistry _sessions = new();
|
||||
@@ -31,27 +36,34 @@ public sealed class AgentApp : IDisposable
|
||||
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly AgentAdmission _admission;
|
||||
private readonly Outbox _outbox = new(64 << 20);
|
||||
private readonly UpdateBridge _update;
|
||||
private readonly RestartUpdater? _update;
|
||||
private readonly OpenWrtUpdater? _nativeUpdate;
|
||||
private readonly DialerHost _dialer;
|
||||
private readonly BackendUplink _uplink;
|
||||
private readonly UdpMux _udp;
|
||||
private readonly QuicDatagramTransport _datagrams;
|
||||
private readonly TcpAcceptor _tcp;
|
||||
private readonly PeerListener _peers;
|
||||
private readonly PeerHub _hub;
|
||||
private readonly ChunkAssembler _chunks;
|
||||
private readonly RateChangeLog _rateChanges = new();
|
||||
private readonly TaskManager _tasks;
|
||||
private readonly TestRunner _tests;
|
||||
private readonly FleetLink _link;
|
||||
private int _disposed;
|
||||
|
||||
/// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param>
|
||||
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
||||
{
|
||||
_ownedRestart = restart is null ? new RestartController() : null;
|
||||
_restart = restart ?? _ownedRestart!;
|
||||
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
|
||||
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
|
||||
_update = RestartUpdater.FromEnvironment("agent", _restart.RequestNow, time, log);
|
||||
_nativeUpdate = OpenWrtUpdater.FromEnvironment(time, log);
|
||||
_config = config;
|
||||
_listening = config.Listen.Enabled;
|
||||
_identity = identity;
|
||||
_time = time;
|
||||
_log = log;
|
||||
_logSource = log as INodeLogSource;
|
||||
_clock = new MonoClock(time);
|
||||
_uplink = new BackendUplink(time);
|
||||
|
||||
@@ -61,37 +73,44 @@ public sealed class AgentApp : IDisposable
|
||||
bind = IPAddress.Any;
|
||||
}
|
||||
|
||||
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
||||
_ipv6Supported = _listening ? bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 : null;
|
||||
_admission = new AgentAdmission(_streams);
|
||||
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
||||
QuicSettings quic = config.Quic.ToSettings();
|
||||
if (quic.Validate() is { } invalid)
|
||||
{
|
||||
throw new InvalidOperationException("agent.json quic: " + invalid);
|
||||
}
|
||||
|
||||
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.QuicPorts, identity.Certificate, quic);
|
||||
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
|
||||
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
|
||||
_dialer = new DialerHost(new DialEnv(identity.PeerIdentity, identity.NodeId.Value, config.Limits, quic, new DialDirectory(new ConfigStore(dataDirectory), dataDirectory, log, _trust), _clock, time, log, _rateChanges), time, log);
|
||||
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
|
||||
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
|
||||
|
||||
// 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
|
||||
// 监听方积木的环境:和拨号方的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
|
||||
var env = new BlockEnv
|
||||
{
|
||||
Clock = _clock,
|
||||
Log = log,
|
||||
Session = () => null, // 每个 Probe 会话各用自己绑定的环境(BlockEnv.WithSession)
|
||||
Udp = _udp,
|
||||
Session = () => null, // 每个拨号方会话各用自己绑定的环境(BlockEnv.WithSession)
|
||||
Datagrams = _datagrams,
|
||||
TcpListen = _tcp,
|
||||
Admission = _admission,
|
||||
Counters = _counters.Data,
|
||||
MaxPacketsPerSecond = config.Limits.MaxTotalPps,
|
||||
MaxBulkMbps = config.Limits.MaxTotalMbps,
|
||||
};
|
||||
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
||||
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
|
||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
|
||||
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
|
||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
||||
_chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
||||
var targets = new TargetRunners(_chunks, identity.NodeId, _clock, time, log, _rateChanges);
|
||||
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _datagrams.BoundPorts, () => _tcp.BoundPorts, _trust, acceptLineTasks: _listening);
|
||||
_hub = new PeerHub(_sessions, _clock, _chunks, _tasks, _uplink, time, log);
|
||||
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "quic_datagram" ? _datagrams.BoundPorts : _tcp.BoundPorts, time, log);
|
||||
|
||||
_tasks.OnLineTasksChanged = (probe, set) => _ = _hub.PushToAsync(probe, set).AsTask();
|
||||
_tasks.OnLineTasksChanged = (dialer, set) => _ = _hub.PushToAsync(dialer, set).AsTask();
|
||||
_tasks.OnPeersChanged = _hub.CloseNotIn;
|
||||
_hub.OnProbeLost = probe => _tests.ProbeLost(probe);
|
||||
_admission.OnEndpointChanged = probe => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, probe.UdpEndpoint?.ToString()).AsTask();
|
||||
_hub.OnDialerLost = dialer => _tests.DialerLost(dialer);
|
||||
_admission.OnEndpointChanged = dialer => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, dialer.DatagramRemoteEndPoint?.ToString()).AsTask();
|
||||
|
||||
_peers = new PeerListener(
|
||||
new PeerListenerOptions
|
||||
@@ -114,13 +133,31 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_udp.Dispose();
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dialer.Dispose();
|
||||
_datagrams.Dispose();
|
||||
_ownedRestart?.Dispose();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dialer.Dispose();
|
||||
await _datagrams.DisposeAsync().ConfigureAwait(false);
|
||||
_ownedRestart?.Dispose();
|
||||
}
|
||||
|
||||
internal StreamRegistry Streams => _streams;
|
||||
|
||||
public IReadOnlyList<int> UdpPorts => _udp.BoundPorts;
|
||||
public IReadOnlyList<int> QuicPorts => _datagrams.BoundPorts;
|
||||
|
||||
public IReadOnlyList<int> TcpPorts => _tcp.BoundPorts;
|
||||
|
||||
@@ -133,40 +170,56 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
public FleetLink Link => _link;
|
||||
|
||||
/// <summary>对等面(Probe 连进来的会话)的路由器,测试用。</summary>
|
||||
/// <summary>拨号侧(测试和状态查看用)。</summary>
|
||||
public DialerHost Dialer => _dialer;
|
||||
|
||||
/// <summary>对等面(拨号方连进来的会话)的路由器,测试用。</summary>
|
||||
public RpcRouter<PeerContext> PeerRouter => _hub.Router;
|
||||
|
||||
/// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary>
|
||||
public bool IsPeerKnown(string probeId, string publicKeyBase64) =>
|
||||
_tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
|
||||
/// <summary>期望状态里是否已经有这个拨号方的公钥(测试用:握手会按它校验)。</summary>
|
||||
public bool IsPeerKnown(string dialerId, string publicKeyBase64) =>
|
||||
_tasks.LookupPeerKey(dialerId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
|
||||
|
||||
public TestRunner Tests => _tests;
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
|
||||
_udp.Bind();
|
||||
_tcp.Bind();
|
||||
Task udp = _udp.RunAsync(linked.Token);
|
||||
Task tcp = _tcp.RunAsync(linked.Token);
|
||||
Task peers = _peers.RunAsync(linked.Token);
|
||||
await Task.WhenAny(_peers.Started.Task, peers);
|
||||
if (peers.IsFaulted)
|
||||
Task quic = Task.CompletedTask;
|
||||
Task tcp = Task.CompletedTask;
|
||||
Task peers = Task.CompletedTask;
|
||||
if (_listening)
|
||||
{
|
||||
await linked.CancelAsync();
|
||||
await Task.WhenAll(udp, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
||||
// 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口
|
||||
_datagrams.Bind();
|
||||
_tcp.Bind();
|
||||
quic = _datagrams.RunAsync(linked.Token);
|
||||
tcp = _tcp.RunAsync(linked.Token);
|
||||
peers = _peers.RunAsync(linked.Token);
|
||||
await Task.WhenAny(_peers.Started.Task, peers);
|
||||
if (peers.IsFaulted)
|
||||
{
|
||||
await linked.CancelAsync();
|
||||
await Task.WhenAll(quic, tcp, peers); // 抛出监听失败的原因(端口被占用等)
|
||||
}
|
||||
|
||||
_log.Info("agent listening", ("control", ControlPort), ("quic", string.Join(',', QuicPorts)), ("tcp", string.Join(',', TcpPorts)));
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Info("agent is dial-only: listening is disabled");
|
||||
}
|
||||
|
||||
_ready.TrySetResult();
|
||||
_log.Info("agent listening", ("control", ControlPort), ("udp", string.Join(',', UdpPorts)), ("tcp", string.Join(',', TcpPorts)));
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(udp, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token));
|
||||
await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), _dialer.RunAsync(linked.Token), ReportHostAsync(linked.Token), _chunks.RunTrafficReportingAsync(linked.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _tasks.StopAsync();
|
||||
_chunks.FlushTraffic();
|
||||
await linked.CancelAsync();
|
||||
}
|
||||
}
|
||||
@@ -203,14 +256,14 @@ public sealed class AgentApp : IDisposable
|
||||
MaxTotalPps = _config.Limits.MaxTotalPps,
|
||||
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
|
||||
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
|
||||
AllowedUdpPorts = _udp.BoundPorts,
|
||||
AllowedQuicPorts = _datagrams.BoundPorts,
|
||||
AllowedTcpPorts = _tcp.BoundPorts,
|
||||
},
|
||||
BlockCatalog.AgentKinds);
|
||||
_listening ? BlockCatalog.AgentKinds : BlockCatalog.TargetKinds);
|
||||
|
||||
private FleetLink BuildLink()
|
||||
{
|
||||
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Agent);
|
||||
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table);
|
||||
HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null;
|
||||
bool Allow(string method) => allowed is null || allowed.Contains(method);
|
||||
|
||||
@@ -236,7 +289,8 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
|
||||
{
|
||||
_update.WriteOffer(offer);
|
||||
_update?.Offer(offer);
|
||||
_nativeUpdate?.Offer();
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
@@ -258,18 +312,45 @@ public sealed class AgentApp : IDisposable
|
||||
{
|
||||
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
|
||||
{
|
||||
_update.RequestApply();
|
||||
_update?.RequestApply();
|
||||
_nativeUpdate?.RequestApply();
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
if (Allow(MoShiroMethods.ProbeDisconnect))
|
||||
if (Allow(MoShiroMethods.DialerDisconnect))
|
||||
{
|
||||
router.On(
|
||||
MoShiroMethods.ProbeDisconnect,
|
||||
MoShiroJsonContext.Default.ProbeDisconnectParams,
|
||||
MoShiroMethods.DialerDisconnect,
|
||||
MoShiroJsonContext.Default.DialerDisconnectParams,
|
||||
MoShiroJsonContext.Default.TestRunResult,
|
||||
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) }));
|
||||
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.DialerId.Value) }));
|
||||
}
|
||||
|
||||
if (Allow(MoShiroMethods.LogsFetch))
|
||||
{
|
||||
router.On(
|
||||
MoShiroMethods.LogsFetch,
|
||||
MoShiroJsonContext.Default.LogsFetchParams,
|
||||
MoShiroJsonContext.Default.LogsFetchResult,
|
||||
(_, request, _) =>
|
||||
{
|
||||
NodeLogPage page = _logSource?.Read(request.AfterId, request.Limit, request.MinLevel) ?? new NodeLogPage([], 0);
|
||||
return ValueTask.FromResult(Result<LogsFetchResult>.Ok(new LogsFetchResult
|
||||
{
|
||||
LastId = page.LastId,
|
||||
Items = [.. page.Items.Select(e => new LogEntryDto { Id = e.Id, Type = e.Type, AtUtcMs = e.Record.AtUtcMs, Level = e.Record.Level, Message = e.Record.Message, Fields = e.Record.Fields })],
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (Allow(MoShiroMethods.LinkRetry))
|
||||
{
|
||||
router.OnNotify(MoShiroMethods.LinkRetry, MoShiroJsonContext.Default.LinkRetryParams, (_, request, _) =>
|
||||
{
|
||||
_dialer.RetryListener(request.ListenerId.Value);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
var options = new FleetLinkOptions
|
||||
@@ -277,21 +358,35 @@ public sealed class AgentApp : IDisposable
|
||||
Server = new Uri(_config.Fleet.Server),
|
||||
Identity = _identity.Identity,
|
||||
ServerVerifier = _trust,
|
||||
Attestation = _update.Attest,
|
||||
NodeId = _identity.NodeId,
|
||||
NodeVersion = Version,
|
||||
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
||||
MinServerVersion = () => Environment.GetEnvironmentVariable("MOSHIRO_MIN_SERVER_VERSION") is { Length: > 0 } required ? required : _config.Fleet.MinServerVersion,
|
||||
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', _listening ? BlockCatalog.AgentKinds : BlockCatalog.TargetKinds)],
|
||||
HelloMeta = HelloMeta,
|
||||
HeartbeatExtra = HeartbeatExtra,
|
||||
ShutdownReason = () => _restart.NowReason == "update" ? "updating" : "stopping",
|
||||
};
|
||||
var link = new FleetLink(options, router, [new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log);
|
||||
var link = new FleetLink(
|
||||
options,
|
||||
router,
|
||||
[
|
||||
new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync),
|
||||
new StateHandler<DialTargets>(DialTargets.StateType, MoShiroJsonContext.Default.DialTargets, (version, targets, _) =>
|
||||
{
|
||||
_dialer.Directory.Apply(targets);
|
||||
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
|
||||
}),
|
||||
],
|
||||
_outbox,
|
||||
_time,
|
||||
_log);
|
||||
|
||||
// 链路重新连上之后补报:在线的 Probe 都告诉 Backend(Backend 重启或链路抖动期间错过了 probe_connected)
|
||||
// 链路重新连上之后补报:在线的拨号方都告诉 Backend(Backend 重启或链路抖动期间错过了 dialer_connected)
|
||||
link.OnConnected = async (_, _) =>
|
||||
{
|
||||
foreach (ConnectedProbe probe in _hub.ConnectedProbes())
|
||||
foreach (ConnectedDialer dialer in _hub.ConnectedDialers())
|
||||
{
|
||||
await _uplink.EmitEventAsync(AgentEventKinds.ProbeConnected, probe.Id.Value, probe);
|
||||
await _uplink.EmitEventAsync(AgentEventKinds.DialerConnected, dialer.Id.Value, dialer);
|
||||
}
|
||||
};
|
||||
return link;
|
||||
@@ -302,16 +397,11 @@ public sealed class AgentApp : IDisposable
|
||||
|
||||
private JsonElement? HelloMeta()
|
||||
{
|
||||
var meta = new AgentHelloMeta
|
||||
var meta = new NodeHelloMeta
|
||||
{
|
||||
Owners = [.. _trust.Controllers],
|
||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||
Ipv6Supported = _ipv6Supported,
|
||||
Addresses = [.. LocalAddresses.Sample()],
|
||||
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
|
||||
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
|
||||
ProbeTcpPort = ControlPort,
|
||||
ProbeUdpPorts = [.. UdpPorts],
|
||||
LocalLimits = new LocalLimits
|
||||
{
|
||||
MaxTotalMbps = _config.Limits.MaxTotalMbps,
|
||||
@@ -319,8 +409,17 @@ public sealed class AgentApp : IDisposable
|
||||
AllowedTargetCidrs = _config.Limits.AllowedTargetCidrs,
|
||||
MaxConcurrentTests = _config.Limits.MaxConcurrentTests,
|
||||
},
|
||||
Listen = !_listening
|
||||
? null
|
||||
: new ListenInfo
|
||||
{
|
||||
TcpPort = ControlPort,
|
||||
QuicPorts = [.. QuicPorts],
|
||||
Ipv6Supported = _ipv6Supported == true,
|
||||
Addresses = [.. LocalAddresses.Sample()],
|
||||
},
|
||||
};
|
||||
return JsonSerializer.SerializeToElement(meta, MoShiroJsonContext.Default.AgentHelloMeta);
|
||||
return JsonSerializer.SerializeToElement(meta, MoShiroJsonContext.Default.NodeHelloMeta);
|
||||
}
|
||||
|
||||
private long _lastIn;
|
||||
@@ -333,21 +432,33 @@ public sealed class AgentApp : IDisposable
|
||||
long seconds = Math.Max(1, (nowMs - _lastAtMs) / 1000);
|
||||
long packetsIn = _counters.PacketsIn;
|
||||
long packetsOut = _counters.PacketsOut;
|
||||
var extra = new AgentHeartbeatExtra
|
||||
NodeLogSummary? logSummary = _logSource?.TakeSummary();
|
||||
UpdateStatus? updateStatus = _nativeUpdate?.Status() ?? _update?.Status();
|
||||
var extra = new NodeHeartbeatExtra
|
||||
{
|
||||
RuntimeStatus = updateStatus?.State == "applying" ? NodeRuntimeStatus.Updating : !_listening && _dialer.Directory.Targets.Count == 0 ? NodeRuntimeStatus.Idle : NodeRuntimeStatus.Running,
|
||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||
Probes = [.. _hub.ConnectedProbes()],
|
||||
Sessions = _sessions.All.Count,
|
||||
Links = _dialer.SnapshotLinks(),
|
||||
Dialers = [.. _hub.ConnectedDialers()],
|
||||
PpsIn = _lastAtMs == 0 ? 0 : (packetsIn - _lastIn) / seconds,
|
||||
PpsOut = _lastAtMs == 0 ? 0 : (packetsOut - _lastOut) / seconds,
|
||||
DroppedInvalid = _counters.DroppedInvalid,
|
||||
DroppedRateLimited = _counters.DroppedRateLimited,
|
||||
QueueBytes = _outbox.PendingBytes,
|
||||
Update = _update.ReadStatus(),
|
||||
Update = updateStatus,
|
||||
RateChanges = _rateChanges.Take(64),
|
||||
LogSummary = logSummary is null ? null : new LogSummaryDto
|
||||
{
|
||||
LastId = logSummary.LastId,
|
||||
Info = logSummary.Info,
|
||||
Warn = logSummary.Warn,
|
||||
Error = logSummary.Error,
|
||||
Types = [.. logSummary.Types.Select(t => new LogTypeCountDto { Type = t.Type, Level = t.Level, Count = t.Count })],
|
||||
},
|
||||
};
|
||||
_lastIn = packetsIn;
|
||||
_lastOut = packetsOut;
|
||||
_lastAtMs = nowMs;
|
||||
return JsonSerializer.SerializeToElement(extra, MoShiroJsonContext.Default.AgentHeartbeatExtra);
|
||||
return JsonSerializer.SerializeToElement(extra, MoShiroJsonContext.Default.NodeHeartbeatExtra);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MoShiro.Measurement;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
@@ -14,6 +15,9 @@ public sealed class AgentConfig
|
||||
[JsonPropertyName("listen")]
|
||||
public ListenSection Listen { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("quic")]
|
||||
public QuicSection Quic { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("limits")]
|
||||
public LimitsSection Limits { get; set; } = new();
|
||||
|
||||
@@ -28,16 +32,6 @@ public sealed class AgentConfig
|
||||
public string? RestartAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReportingSection
|
||||
{
|
||||
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
|
||||
[JsonPropertyName("host_stats")]
|
||||
public bool HostStats { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("host_interval_s")]
|
||||
public int HostIntervalSeconds { get; set; } = 2;
|
||||
}
|
||||
|
||||
public sealed class FleetSection
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
@@ -49,24 +43,53 @@ public sealed class FleetSection
|
||||
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
|
||||
[JsonPropertyName("require_owner")]
|
||||
public bool RequireOwner { get; set; } = true;
|
||||
|
||||
/// <summary>面板至少要是哪个版本才继续连接(如 <c>0.4.2-dev.80</c>);空 = 不要求。不够就不发任何东西,5 秒后重新握手(面板可能正在更新)。环境变量 <c>MOSHIRO_MIN_SERVER_VERSION</c> 优先。</summary>
|
||||
[JsonPropertyName("min_server_version")]
|
||||
public string? MinServerVersion { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ListenSection
|
||||
{
|
||||
/// <summary>是否接受拨号方的连接。关闭时节点只拨出:不开任何端口,在 Backend 的目录里也不是拨号目标(家宽和 NAT 后的节点用这个配置)。</summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("bind")]
|
||||
public string Bind { get; set; } = "::";
|
||||
|
||||
/// <summary>Probe 控制通道(TLS,点对点会话)。</summary>
|
||||
/// <summary>拨号方 控制通道(TLS,点对点会话)。</summary>
|
||||
[JsonPropertyName("control_port")]
|
||||
public int ControlPort { get; set; } = 7443;
|
||||
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int> UdpPorts { get; set; } = [7443];
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [7443];
|
||||
|
||||
[JsonPropertyName("tcp_ports")]
|
||||
public List<int> TcpPorts { get; set; } = [7444];
|
||||
}
|
||||
|
||||
/// <summary>数据通道 QUIC 连接的传输参数;不写的项用 MsQuic 默认值。监听和拨出的连接都用这里的值。</summary>
|
||||
public sealed class QuicSection
|
||||
{
|
||||
[JsonPropertyName("min_mtu")]
|
||||
public int? MinMtu { get; set; }
|
||||
|
||||
[JsonPropertyName("max_mtu")]
|
||||
public int? MaxMtu { get; set; }
|
||||
|
||||
[JsonPropertyName("pacing")]
|
||||
public bool? Pacing { get; set; }
|
||||
|
||||
[JsonPropertyName("idle_timeout_ms")]
|
||||
public int? IdleTimeoutMs { get; set; }
|
||||
|
||||
[JsonPropertyName("keep_alive_ms")]
|
||||
public int? KeepAliveMs { get; set; }
|
||||
|
||||
public QuicSettings ToSettings() => new() { MinMtu = MinMtu, MaxMtu = MaxMtu, Pacing = Pacing, IdleTimeoutMs = IdleTimeoutMs, KeepAliveMs = KeepAliveMs };
|
||||
}
|
||||
|
||||
public sealed class LimitsSection
|
||||
{
|
||||
[JsonPropertyName("max_total_mbps")]
|
||||
@@ -75,7 +98,7 @@ public sealed class LimitsSection
|
||||
[JsonPropertyName("max_total_pps")]
|
||||
public long MaxTotalPps { get; set; } = 20_000;
|
||||
|
||||
/// <summary>默认为空:不允许执行任何目标任务。</summary>
|
||||
/// <summary>单个 Agent 任务(外部目标)能探测的网段。默认为空:只允许公网地址,内网、回环、链路本地等一律拒绝;写了就只允许列出的网段(可以包含内网)。</summary>
|
||||
[JsonPropertyName("allowed_target_cidrs")]
|
||||
public List<string> AllowedTargetCidrs { get; set; } = [];
|
||||
|
||||
@@ -91,4 +114,6 @@ public sealed class PolicySection
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(AgentConfig))]
|
||||
[JsonSerializable(typeof(DialCache))]
|
||||
[JsonSerializable(typeof(OpenWrtHealth))]
|
||||
public sealed partial class AgentConfigContext : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
|
||||
/// 拨号方 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
|
||||
/// 找不到对应流的列块(任务已经被撤销、测试已经结束)被丢弃并计数;这只会发生在配置变更的瞬间。
|
||||
/// </summary>
|
||||
public sealed class ChunkAssembler
|
||||
@@ -18,6 +18,7 @@ public sealed class ChunkAssembler
|
||||
private readonly NodeId _agent;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
private readonly TaskTrafficReporter _traffic;
|
||||
|
||||
public ChunkAssembler(StreamRegistry streams, MonoClock clock, AgentCounters counters, Outbox outbox, NodeId agent, TimeProvider time, INodeLog log)
|
||||
{
|
||||
@@ -28,23 +29,24 @@ public sealed class ChunkAssembler
|
||||
_agent = agent;
|
||||
_time = time;
|
||||
_log = log;
|
||||
_traffic = new TaskTrafficReporter(log, time);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理某个 Probe 交来的一个列块(已经解码)。返回是否被接受。
|
||||
/// 块头里的 <c>probe_id</c> 以认证过的会话为准(Probe 自报的值不可信),别的 Probe 的流也不会被命中。
|
||||
/// 处理某个拨号方交来的一个列块(已经解码)。返回是否被接受。
|
||||
/// 块头里的 <c>dialer_id</c> 以认证过的会话为准(拨号方 自报的值不可信),别的拨号方的流也不会被命中。
|
||||
/// </summary>
|
||||
public bool Accept(RawChunk fragment, ProbeSession probe)
|
||||
public bool Accept(RawChunk fragment, DialerSession dialer)
|
||||
{
|
||||
RawChunkHeader header = fragment.Header;
|
||||
StreamInfo? stream = header switch
|
||||
{
|
||||
{ TestId: { } test, Phase: { } phase } => _streams.FindByTest(test, phase) is { } found && ReferenceEquals(found.Owner, probe) ? found : null,
|
||||
{ TaskId: { } task } => _streams.FindByTask(task, probe),
|
||||
{ TestId: { } test, Phase: { } phase } => _streams.FindByTest(test, phase) is { } found && ReferenceEquals(found.Owner, dialer) ? found : null,
|
||||
{ TaskId: { } task } => _streams.FindByTask(task, dialer),
|
||||
_ => null,
|
||||
};
|
||||
header.ProbeId = new ProbeId(probe.ProbeId);
|
||||
header.IpVersion = probe.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
|
||||
header.DialerId = new NodeId(dialer.DialerId);
|
||||
header.IpVersion = dialer.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
|
||||
KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind);
|
||||
if (stream is null || spec is null || spec.Id != header.Kind)
|
||||
{
|
||||
@@ -68,9 +70,20 @@ public sealed class ChunkAssembler
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task RunTrafficReportingAsync(CancellationToken ct) => _traffic.RunAsync(ct);
|
||||
|
||||
public void FlushTraffic() => _traffic.Flush();
|
||||
|
||||
/// <summary>Agent 自己采集的块(目标任务)直接发出。</summary>
|
||||
public void Emit(RawChunk chunk)
|
||||
{
|
||||
_outbox.Enqueue(MoShiroChannels.RawChunk, chunk.Header.ChunkId.Value, RawChunkCodec.Encode(chunk));
|
||||
byte[] payload = RawChunkCodec.Encode(chunk, out int uncompressedBytes);
|
||||
_outbox.Enqueue(MoShiroChannels.RawChunk, chunk.Header.ChunkId.Value, payload);
|
||||
_traffic.Record(
|
||||
chunk.Header.TaskId?.Value ?? chunk.Header.TestId?.Value ?? "unknown",
|
||||
chunk.Header.Kind,
|
||||
chunk.Streams.Sum(stream => (long)stream.Count),
|
||||
uncompressedBytes,
|
||||
payload.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>Aggregates per-task Agent → Backend chunk sizes into low-volume diagnostic logs.</summary>
|
||||
internal sealed class TaskTrafficReporter
|
||||
{
|
||||
private static readonly TimeSpan s_window = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly INodeLog _log;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Dictionary<(string Task, string Kind), Totals> _totals = [];
|
||||
private long _windowStarted;
|
||||
|
||||
public TaskTrafficReporter(INodeLog log, TimeProvider time)
|
||||
{
|
||||
_log = log;
|
||||
_time = time;
|
||||
_windowStarted = time.GetTimestamp();
|
||||
}
|
||||
|
||||
public void Record(string task, string kind, long records, int uncompressedBytes, int compressedBytes)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var key = (task, kind);
|
||||
if (!_totals.TryGetValue(key, out Totals? totals))
|
||||
{
|
||||
totals = new Totals();
|
||||
_totals.Add(key, totals);
|
||||
}
|
||||
|
||||
totals.Chunks++;
|
||||
totals.Records += records;
|
||||
totals.UncompressedBytes += uncompressedBytes;
|
||||
totals.CompressedBytes += compressedBytes;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(s_window, _time);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
Flush();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The final snapshot is flushed by the Agent shutdown path.
|
||||
}
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
List<Snapshot> completed;
|
||||
long windowSeconds;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_totals.Count == 0)
|
||||
{
|
||||
_windowStarted = _time.GetTimestamp();
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan elapsed = _time.GetElapsedTime(_windowStarted);
|
||||
windowSeconds = Math.Max(1, (long)elapsed.TotalSeconds);
|
||||
completed = [.. _totals.Select(pair => new Snapshot(pair.Key.Task, pair.Key.Kind, pair.Value.Chunks, pair.Value.Records, pair.Value.UncompressedBytes, pair.Value.CompressedBytes))];
|
||||
_totals.Clear();
|
||||
_windowStarted = _time.GetTimestamp();
|
||||
}
|
||||
|
||||
foreach (Snapshot item in completed)
|
||||
{
|
||||
double savedPercent = item.UncompressedBytes == 0
|
||||
? 0
|
||||
: 100d * (item.UncompressedBytes - item.CompressedBytes) / item.UncompressedBytes;
|
||||
long queuedBytes = item.CompressedBytes + (long)item.Chunks * OutboxFrame.HeaderBytes;
|
||||
_log.Info(
|
||||
"task upload traffic",
|
||||
("link", "agent-backend"),
|
||||
("task", item.Task),
|
||||
("kind", item.Kind),
|
||||
("chunks", item.Chunks),
|
||||
("records", item.Records),
|
||||
("raw_bytes", item.UncompressedBytes),
|
||||
("compressed_bytes", item.CompressedBytes),
|
||||
("queued_bytes", queuedBytes),
|
||||
("saved_pct", Math.Round(savedPercent, 1)),
|
||||
("window_s", windowSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Totals
|
||||
{
|
||||
public long Chunks;
|
||||
public long Records;
|
||||
public long UncompressedBytes;
|
||||
public long CompressedBytes;
|
||||
}
|
||||
|
||||
private sealed record Snapshot(string Task, string Kind, long Chunks, long Records, long UncompressedBytes, long CompressedBytes);
|
||||
}
|
||||
@@ -34,19 +34,19 @@ public sealed class AgentCounters
|
||||
public void FragmentDiscarded() => Interlocked.Increment(ref _fragmentsDiscarded);
|
||||
}
|
||||
|
||||
public sealed record PeerContext(ProbeSession Session, MonoClock Clock);
|
||||
public sealed record PeerContext(DialerSession Session, MonoClock Clock);
|
||||
|
||||
/// <summary>
|
||||
/// 一个已经认证的 Probe 会话:数据面的 <c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、
|
||||
/// 一个已经认证的拨号方会话:数据面的 <c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、
|
||||
/// 每个流的重放窗口。数据面的包不绑定五元组,NAT 映射变化之后自动跟随新的源地址。
|
||||
/// </summary>
|
||||
public sealed class ProbeSession : DataSession
|
||||
public sealed class DialerSession : DataSession
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
|
||||
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
|
||||
private SocketAddress? _udpSource;
|
||||
private SocketAddress? _datagramSource;
|
||||
|
||||
public ProbeSession(PeerSessionInfo info)
|
||||
public DialerSession(PeerSessionInfo info)
|
||||
: base(info.Credentials.SessionIdValue, info.Credentials.Key)
|
||||
{
|
||||
Info = info;
|
||||
@@ -54,16 +54,20 @@ public sealed class ProbeSession : DataSession
|
||||
|
||||
public PeerSessionInfo Info { get; }
|
||||
|
||||
public string ProbeId => Info.PeerId;
|
||||
public string DialerId => Info.PeerId;
|
||||
|
||||
public override string DiagnosticPeerId => DialerId;
|
||||
|
||||
public string? CloseReason { get; set; }
|
||||
|
||||
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;
|
||||
public string PathKey => DialerId + "~" + IpVersion;
|
||||
|
||||
/// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary>
|
||||
/// <summary>拨号方 的程序版本(握手里自报,只用于展示)。</summary>
|
||||
public string? Version => Info.Version;
|
||||
|
||||
/// <summary>Probe 的远端地址(经过 NAT 之后的公网 IP)。</summary>
|
||||
/// <summary>拨号方 的远端地址(经过 NAT 之后的公网 IP)。</summary>
|
||||
public string? RemoteAddress => (Info.Remote as IPEndPoint)?.Address.ToString();
|
||||
|
||||
public long ConnectedAtUtcMs { get; init; }
|
||||
@@ -71,11 +75,11 @@ public sealed class ProbeSession : DataSession
|
||||
public RpcSession<PeerContext>? Rpc { get; set; }
|
||||
|
||||
/// <summary>最近一次认证通过的 UDP 包的源地址(Agent 把单向数据发往这里)。</summary>
|
||||
public override IPEndPoint? UdpEndpoint
|
||||
public override IPEndPoint? DatagramRemoteEndPoint
|
||||
{
|
||||
get
|
||||
{
|
||||
SocketAddress? source = Volatile.Read(ref _udpSource);
|
||||
SocketAddress? source = Volatile.Read(ref _datagramSource);
|
||||
return source is null ? null : (IPEndPoint)new IPEndPoint(source.Family == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, 0).Create(source);
|
||||
}
|
||||
}
|
||||
@@ -83,7 +87,7 @@ public sealed class ProbeSession : DataSession
|
||||
/// <summary>记录 UDP 源地址;NAT 映射变化后地址变了返回 true(上报 endpoint_changed)。第一次看到地址不算变化。</summary>
|
||||
public bool UpdateSource(SocketAddress source)
|
||||
{
|
||||
SocketAddress? previous = Volatile.Read(ref _udpSource);
|
||||
SocketAddress? previous = Volatile.Read(ref _datagramSource);
|
||||
if (previous is not null && previous.Equals(source))
|
||||
{
|
||||
return false;
|
||||
@@ -96,7 +100,7 @@ public sealed class ProbeSession : DataSession
|
||||
copy[i] = source[i];
|
||||
}
|
||||
|
||||
Volatile.Write(ref _udpSource, copy);
|
||||
Volatile.Write(ref _datagramSource, copy);
|
||||
return previous is not null;
|
||||
}
|
||||
|
||||
@@ -129,59 +133,59 @@ public sealed class ProbeSession : DataSession
|
||||
public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _);
|
||||
}
|
||||
|
||||
/// <summary>每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
|
||||
/// <summary>每个拨号方的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
|
||||
public sealed class SessionRegistry : IDataSessions
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, ProbeSession> _byProbe = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<long, ProbeSession> _bySession = [];
|
||||
private readonly Dictionary<string, DialerSession> _byDialer = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<long, DialerSession> _bySession = [];
|
||||
|
||||
public IReadOnlyList<ProbeSession> All
|
||||
public IReadOnlyList<DialerSession> All
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return [.. _byProbe.Values];
|
||||
return [.. _byDialer.Values];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ProbeSession? Get(string probeId, string ipVersion = IpVersions.V4)
|
||||
public DialerSession? Get(string dialerId, string ipVersion = IpVersions.V4)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _byProbe.GetValueOrDefault(probeId + "~" + ipVersion);
|
||||
return _byDialer.GetValueOrDefault(dialerId + "~" + ipVersion);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登记新会话;返回同一个 Probe 的旧会话(如果有)。</summary>
|
||||
public ProbeSession? Replace(ProbeSession session)
|
||||
/// <summary>登记新会话;返回同一个拨号方的旧会话(如果有)。</summary>
|
||||
public DialerSession? Replace(DialerSession session)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_byProbe.TryGetValue(session.PathKey, out ProbeSession? old);
|
||||
_byDialer.TryGetValue(session.PathKey, out DialerSession? old);
|
||||
if (old is not null)
|
||||
{
|
||||
_bySession.Remove(old.SessionId);
|
||||
}
|
||||
|
||||
_byProbe[session.PathKey] = session;
|
||||
_byDialer[session.PathKey] = session;
|
||||
_bySession[session.SessionId] = session;
|
||||
return old;
|
||||
}
|
||||
}
|
||||
|
||||
public void Remove(ProbeSession session)
|
||||
public void Remove(DialerSession session)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_byProbe.TryGetValue(session.PathKey, out ProbeSession? current) && ReferenceEquals(current, session))
|
||||
if (_byDialer.TryGetValue(session.PathKey, out DialerSession? current) && ReferenceEquals(current, session))
|
||||
{
|
||||
_byProbe.Remove(session.PathKey);
|
||||
_byDialer.Remove(session.PathKey);
|
||||
}
|
||||
|
||||
if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session))
|
||||
if (_bySession.TryGetValue(session.SessionId, out DialerSession? bySession) && ReferenceEquals(bySession, session))
|
||||
{
|
||||
_bySession.Remove(session.SessionId);
|
||||
}
|
||||
@@ -213,8 +217,8 @@ public sealed class StreamInfo
|
||||
KindSpec spec = Kinds.Find(kind)!;
|
||||
DeclaredPps = kind switch
|
||||
{
|
||||
"udp_echo" or "tcp_echo" => parameters.GetInt("rate_hz"),
|
||||
"udp_train" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
|
||||
"quic_ping" or "tcp_ping" => Kinds.PeakRateHz(kind, parameters),
|
||||
"quic_stream" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
|
||||
_ => 20,
|
||||
};
|
||||
Port = spec.Params.Any(p => p.Name == "port") ? (int)parameters.GetInt("port") : 0;
|
||||
@@ -232,8 +236,8 @@ public sealed class StreamInfo
|
||||
|
||||
public int? Phase { get; init; }
|
||||
|
||||
/// <summary>这个流属于哪个 Probe 会话:别的会话的包即使带着这个流编号也不会被接受。</summary>
|
||||
public ProbeSession? Owner { get; init; }
|
||||
/// <summary>这个流属于哪个拨号方会话:别的会话的包即使带着这个流编号也不会被接受。</summary>
|
||||
public DialerSession? Owner { get; init; }
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
@@ -286,7 +290,7 @@ public sealed class StreamRegistry : IRecordSource
|
||||
|
||||
public bool TryGet(uint id, out StreamInfo stream) => _streams.TryGetValue(id, out stream!);
|
||||
|
||||
public StreamInfo? FindByTask(TaskId task, ProbeSession owner) => _streams.Values.FirstOrDefault(s => s.TaskId == task && ReferenceEquals(s.Owner, owner));
|
||||
public StreamInfo? FindByTask(TaskId task, DialerSession owner) => _streams.Values.FirstOrDefault(s => s.TaskId == task && ReferenceEquals(s.Owner, owner));
|
||||
|
||||
public StreamInfo? FindByTest(TestId test, int phase) => _streams.Values.FirstOrDefault(s => s.TestId == test && s.Phase == phase);
|
||||
|
||||
@@ -310,11 +314,11 @@ public sealed class AgentAdmission : IAdmission
|
||||
}
|
||||
|
||||
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
|
||||
public Action<ProbeSession>? OnEndpointChanged { get; set; }
|
||||
public Action<DialerSession>? OnEndpointChanged { get; set; }
|
||||
|
||||
public Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs)
|
||||
public Admission AdmitDatagram(DataSession session, in MeasurementDatagramHeader header, SocketAddress source, long nowUs)
|
||||
{
|
||||
if (session is not ProbeSession probe || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, probe) || !probe.AcceptSequence(header.StreamId, header.Seq))
|
||||
if (session is not DialerSession dialer || !_streams.TryGet(header.StreamId, out StreamInfo? stream) || !ReferenceEquals(stream.Owner, dialer) || !dialer.AcceptSequence(header.StreamId, header.Seq))
|
||||
{
|
||||
return Admission.Invalid;
|
||||
}
|
||||
@@ -324,9 +328,9 @@ public sealed class AgentAdmission : IAdmission
|
||||
return Admission.RateLimited;
|
||||
}
|
||||
|
||||
if (probe.UpdateSource(source))
|
||||
if (dialer.UpdateSource(source))
|
||||
{
|
||||
OnEndpointChanged?.Invoke(probe);
|
||||
OnEndpointChanged?.Invoke(dialer);
|
||||
}
|
||||
|
||||
return Admission.Accepted;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>Reconnect cadence for the control connection of a single dialer-to-listener path: 1, 2, 4, 8, 16, then five 32-second waits, then once per minute.</summary>
|
||||
public sealed class ControlBackoff
|
||||
{
|
||||
private static readonly TimeSpan[] s_delays =
|
||||
[
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(4),
|
||||
TimeSpan.FromSeconds(8),
|
||||
TimeSpan.FromSeconds(16),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32),
|
||||
];
|
||||
|
||||
private const int MaxMinutes = 10;
|
||||
|
||||
private int _attempt;
|
||||
|
||||
public int Attempts => Volatile.Read(ref _attempt);
|
||||
|
||||
public TimeSpan Next()
|
||||
{
|
||||
int attempt = Interlocked.Increment(ref _attempt) - 1;
|
||||
if (attempt < s_delays.Length)
|
||||
{
|
||||
return s_delays[attempt];
|
||||
}
|
||||
|
||||
// 表走完还连不上(对端关了、这台机器没有这条路由):一分钟起继续翻倍,最长十分钟,不再每分钟白白重试一次、写三条日志
|
||||
return TimeSpan.FromMinutes(Math.Min(MaxMinutes, 1 << Math.Min(attempt - s_delays.Length, 4)));
|
||||
}
|
||||
|
||||
public void Reset() => Interlocked.Exchange(ref _attempt, 0);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Link;
|
||||
using Fleet.Rpc;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>所有连接共用的东西。</summary>
|
||||
public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, QuicSettings Quic, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log, RateChangeLog RateChanges);
|
||||
|
||||
/// <summary>
|
||||
/// 与一个 Agent 的点对点连接,分两层:
|
||||
/// <list type="bullet">
|
||||
/// <item>控制层:TCP+TLS 长连接。认证握手 → 同步时钟 → 按 Agent 下发的 <c>line_task_set</c> 运行积木 → 每 10 秒把已完成的行切成列块放进发件箱,
|
||||
/// 发件箱和控制 RPC 共用这条连接。控制层断开才算与 Agent 失联:积木停止(数据面凭据属于控制会话),按 <see cref="ControlBackoff"/> 重拨。</item>
|
||||
/// <item>任务层:TCP 类积木各自拨号;QUIC 数据通道由 <see cref="TaskLink"/> 在控制层在线期间单独维护,断开只重拨 QUIC。</item>
|
||||
/// </list>
|
||||
/// 发件箱属于这条连接,不属于某次控制会话:控制层重连之后重发还没确认的数据块。状态与日志由 <see cref="LinkStatus"/> 统一维护。
|
||||
/// </summary>
|
||||
public sealed class DialConnection : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
|
||||
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Agent 的任何帧(时钟回复、任务集)都算存活;比时钟同步间隔长得多,容得下差线路上的黑洞,但不会让一个 Agent 已经丢掉的会话永远挂着。</summary>
|
||||
private static readonly TimeSpan s_idleLimit = TimeSpan.FromSeconds(120);
|
||||
|
||||
private static readonly RpcRouter<DialSession> s_router = BuildRouter();
|
||||
|
||||
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
|
||||
public static RpcRouter<DialSession> Router => s_router;
|
||||
|
||||
private readonly DialEnv _env;
|
||||
private readonly LinkStatus _status;
|
||||
private readonly Outbox _outbox = new(8 << 20);
|
||||
private readonly SemaphoreSlim _retrySignal = new(0, 1);
|
||||
private INodeLog _attemptLog;
|
||||
private string _phase = "idle";
|
||||
private long _attemptStarted;
|
||||
private volatile DialSession? _session;
|
||||
private volatile TaskLink? _task;
|
||||
private QuicDatagramTransport? _activeDatagrams;
|
||||
|
||||
public DialConnection(DialEndpoint endpoint, DialEnv env)
|
||||
{
|
||||
Endpoint = endpoint;
|
||||
_env = env;
|
||||
_attemptLog = env.Log;
|
||||
_status = new LinkStatus(endpoint.NodeId, endpoint.IpVersion, env.Time, env.Log);
|
||||
}
|
||||
|
||||
public DialEndpoint Endpoint { get; }
|
||||
|
||||
/// <summary>控制层在线,且 QUIC 数据通道可发送。</summary>
|
||||
public bool IsConnected => _session is not null && Volatile.Read(ref _activeDatagrams)?.IsConnected == true;
|
||||
|
||||
public int RoleCount => _session?.RoleCount ?? 0;
|
||||
|
||||
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
|
||||
|
||||
/// <summary>当前线路状态(随心跳上报给面板)。</summary>
|
||||
public LinkHealth Health() =>
|
||||
_status.Snapshot(_outbox, _session?.TcpBlockCount ?? 0, Volatile.Read(ref _activeDatagrams)?.GetTransportSnapshot());
|
||||
|
||||
/// <summary>管理端手动配对:控制层在退避就马上再拨;控制层在线而 QUIC 在重拨,就跳过 QUIC 的等待。</summary>
|
||||
public void RetryNow()
|
||||
{
|
||||
if (_status.Control == ControlState.Up)
|
||||
{
|
||||
if (_status.Quic == QuicState.Retrying)
|
||||
{
|
||||
_task?.RetryNow();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_retrySignal.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
// 已经有一次手动重试排队。
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// 对应线路正在被移除。
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _retrySignal.Dispose();
|
||||
|
||||
/// <summary>控制层循环:拨号,会话结束后按退避重拨。</summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = new ControlBackoff();
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
_attemptStarted = _env.Time.GetTimestamp();
|
||||
_attemptLog = new ContextNodeLog(_env.Log, ("agent", Endpoint.NodeId), ("ip_version", Endpoint.IpVersion),
|
||||
("attempt_id", Guid.NewGuid().ToString("N")));
|
||||
_status.Log = _attemptLog;
|
||||
_phase = "dns";
|
||||
_status.SetControl(ControlState.Dialing, attempt: backoff.Attempts);
|
||||
LinkFault fault;
|
||||
try
|
||||
{
|
||||
fault = await RunOnceAsync(backoff, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
fault = LinkFaults.From(ex, _phase);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
TimeSpan delay = backoff.Next();
|
||||
_status.SetControl(ControlState.Backoff, fault, backoff.Attempts, delay);
|
||||
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task delayTask = Task.Delay(delay, _env.Time, waitCts.Token);
|
||||
Task retryTask = _retrySignal.WaitAsync(waitCts.Token);
|
||||
Task completed = await Task.WhenAny(delayTask, retryTask);
|
||||
await waitCts.CancelAsync();
|
||||
if (completed == retryTask)
|
||||
{
|
||||
await retryTask;
|
||||
_attemptLog.Info("manual link retry requested");
|
||||
}
|
||||
else
|
||||
{
|
||||
await delayTask;
|
||||
try { await retryTask; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>一次控制会话:建立 → 运行 → 结束。建立失败抛出异常;建立之后结束返回结束原因。</summary>
|
||||
private async Task<LinkFault> RunOnceAsync(ControlBackoff backoff, CancellationToken ct)
|
||||
{
|
||||
_attemptLog.Info("agent connection attempt started", ("phase", _phase), ("host", Endpoint.Host),
|
||||
("tcp_port", Endpoint.TcpPort), ("quic_ports", string.Join(',', Endpoint.QuicPorts)));
|
||||
IPAddress address = await ResolveAsync(ct);
|
||||
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
|
||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
|
||||
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.NodeId, AgentApp.Version, 8 << 20, _env.Time, _attemptLog, ct,
|
||||
onPhase: OnPeerPhase);
|
||||
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
||||
var remote = (IPEndPoint)info.Remote!;
|
||||
List<int> quicPorts = Endpoint.QuicPorts;
|
||||
int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0];
|
||||
var datagrams = new QuicDatagramTransport(
|
||||
data,
|
||||
_env.Clock,
|
||||
new DataPlaneCounters(),
|
||||
_attemptLog,
|
||||
new IPEndPoint(remote.Address, quicPort),
|
||||
agentKey,
|
||||
_env.Quic);
|
||||
Volatile.Write(ref _activeDatagrams, datagrams);
|
||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
|
||||
_outbox.ResetSent();
|
||||
var clock = new ClockEstimator();
|
||||
var shipper = new FragmentShipper(_outbox, new NodeId(Endpoint.NodeId), new NodeId(_env.NodeId), clock, _env.Time);
|
||||
var blockEnv = new BlockEnv
|
||||
{
|
||||
Clock = _env.Clock,
|
||||
Log = _attemptLog,
|
||||
Session = () => data,
|
||||
Datagrams = datagrams,
|
||||
PeerAddress = remote.Address,
|
||||
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
|
||||
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
|
||||
Fragments = shipper,
|
||||
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
|
||||
MaxBulkMbps = _env.Limits.MaxTotalMbps,
|
||||
OnRateChange = change =>
|
||||
{
|
||||
change.Listener = Endpoint.NodeId;
|
||||
change.IpVersion = Endpoint.IpVersion;
|
||||
_env.RateChanges.Add(change);
|
||||
},
|
||||
};
|
||||
var session = new DialSession(blockEnv, clock, _outbox, _env);
|
||||
var rpc = new RpcSession<DialSession>(transport, s_router, session, _env.Time);
|
||||
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
||||
|
||||
Task run = rpc.RunAsync(cts.Token);
|
||||
Task pump = PumpAsync(rpc, _outbox, cts.Token);
|
||||
Task quic = Task.CompletedTask;
|
||||
Task maintenance = Task.CompletedTask;
|
||||
TaskLink? taskLink = null;
|
||||
try
|
||||
{
|
||||
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
|
||||
_phase = "clock_sync";
|
||||
_status.SetControl(ControlState.Syncing);
|
||||
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
|
||||
_attemptLog.Info("agent clock synchronized", ("phase", _phase), ("offset_us", clock.OffsetUs));
|
||||
|
||||
backoff.Reset();
|
||||
_retrySignal.Wait(0, CancellationToken.None); // 若“立即重试”恰好赶在本次拨号成功前到达,本次成功已满足请求。
|
||||
_session = session;
|
||||
_phase = "control_session";
|
||||
_status.SetControl(ControlState.Up);
|
||||
taskLink = new TaskLink(datagrams, _status, _env.Time);
|
||||
_task = taskLink;
|
||||
quic = taskLink.RunAsync(cts.Token);
|
||||
maintenance = MaintainAsync(rpc, session, clock, cts.Token);
|
||||
_attemptLog.Info("agent control session up", ("phase", _phase), ("remote", remote), ("quic_port", quicPort), ("offset_us", clock.OffsetUs));
|
||||
Task finished = await Task.WhenAny(run, pump, maintenance, quic);
|
||||
LinkFault fault = finished.IsFaulted && finished.Exception?.GetBaseException() is { } error
|
||||
? LinkFaults.From(error, _phase)
|
||||
: LinkFaults.Closed(_phase, finished == run
|
||||
? "closed by the Agent control connection"
|
||||
: finished == pump
|
||||
? "outbox pump stopped"
|
||||
: finished == quic
|
||||
? "QUIC link loop stopped"
|
||||
: "connection maintenance stopped");
|
||||
|
||||
await cts.CancelAsync();
|
||||
await BoundedAsync("stop tasks", () => Task.WhenAll(Quiet(run), Quiet(pump), Quiet(maintenance), Quiet(quic)));
|
||||
return fault;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_task = null;
|
||||
_session = null;
|
||||
await cts.CancelAsync();
|
||||
session.FlushLineBlocks(); // 停止前把已稳定的行交给发件箱,重连之后补传
|
||||
await BoundedAsync("stop blocks", session.StopAllAsync);
|
||||
await BoundedAsync("wait control", () => Quiet(run));
|
||||
await BoundedAsync("wait pump", () => Quiet(pump));
|
||||
await BoundedAsync("wait quic", () => Quiet(quic));
|
||||
taskLink?.Dispose();
|
||||
await BoundedAsync("dispose control", () => rpc.DisposeAsync().AsTask());
|
||||
Volatile.Write(ref _activeDatagrams, null);
|
||||
await BoundedAsync("dispose quic", () => datagrams.DisposeAsync().AsTask());
|
||||
_status.SetQuic(QuicState.Idle);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IPAddress> ResolveAsync(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)
|
||||
?? throw new SocketException((int)SocketError.HostNotFound);
|
||||
_attemptLog.Info("agent address resolved", ("phase", _phase), ("remote", address),
|
||||
("elapsed_ms", (long)_env.Time.GetElapsedTime(_attemptStarted).TotalMilliseconds));
|
||||
return address;
|
||||
}
|
||||
|
||||
private void OnPeerPhase(string phase)
|
||||
{
|
||||
_phase = phase;
|
||||
if (phase == "peer_auth")
|
||||
{
|
||||
_status.SetControl(ControlState.Authenticating);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BoundedAsync(string step, Func<Task> work)
|
||||
{
|
||||
if (!await Bounded.WaitAsync(work(), Bounded.Teardown, _env.Time))
|
||||
{
|
||||
_attemptLog.Warn("agent session teardown step timed out, moving on", ("phase", _phase), ("step", step), ("timeout_ms", Bounded.Teardown.TotalMilliseconds));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Quiet(Task task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException or TimeoutException)
|
||||
{
|
||||
// 会话结束时的正常噪声
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>回传数据:发件箱里的块经控制连接发给 Agent。</summary>
|
||||
private static async Task PumpAsync(RpcSession<DialSession> rpc, Outbox outbox, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
byte[] frame = await outbox.TakeNextAsync(2 << 20, ct);
|
||||
await rpc.SendBinaryAsync(frame, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncClockAsync(RpcSession<DialSession> rpc, ClockEstimator clock, int rounds, CancellationToken ct)
|
||||
{
|
||||
for (int i = 0; i < rounds; i++)
|
||||
{
|
||||
long sent = _env.Clock.NowUs();
|
||||
Result<ClockResult> reply = await rpc.RequestAsync(MoShiroMethods.Clock, new ClockParams { DialerMonoUs = sent }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, ct);
|
||||
if (reply.TryGet(out ClockResult? value, out _))
|
||||
{
|
||||
clock.Add(sent, value.ListenerMonoUs, _env.Clock.NowUs());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>切块(每 10 秒)、时钟同步(每 30 秒)和控制连接的存活检查。</summary>
|
||||
private async Task MaintainAsync(RpcSession<DialSession> rpc, DialSession session, ClockEstimator clock, CancellationToken ct)
|
||||
{
|
||||
using var flush = new PeriodicTimer(s_flushInterval, _env.Time);
|
||||
long lastClockUs = _env.Clock.NowUs();
|
||||
while (await flush.WaitForNextTickAsync(ct))
|
||||
{
|
||||
if (rpc.IdleFor > s_idleLimit)
|
||||
{
|
||||
// 监听方已经不认这个会话(它重启、重连、或者空闲超时关了会话,而 FIN 没有到达):拨号方这边结束它,让外层按退避重拨
|
||||
throw new ControlSilentException($"the Agent sent nothing on this session for {(long)rpc.IdleFor.TotalSeconds}s");
|
||||
}
|
||||
|
||||
session.FlushLineBlocks();
|
||||
if (_env.Clock.NowUs() - lastClockUs >= s_clockInterval.TotalMicroseconds)
|
||||
{
|
||||
lastClockUs = _env.Clock.NowUs();
|
||||
await SyncClockAsync(rpc, clock, rounds: 1, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RpcRouter<DialSession> BuildRouter() =>
|
||||
new RpcRouter<DialSession>(RpcSide.Dialer, MoShiroMethodTable.Table)
|
||||
.On(
|
||||
FleetMethods.StateSync,
|
||||
FleetRpcContext.Default.StateSyncParams,
|
||||
FleetRpcContext.Default.StateSyncResult,
|
||||
async (session, sync, _) =>
|
||||
{
|
||||
var result = new StateSyncResult { AppliedVersion = sync.Version };
|
||||
if (sync.Type == LineTaskSet.StateType)
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
||||
result.Rejected.AddRange(await session.ApplyAsync(set));
|
||||
}
|
||||
|
||||
return Result<StateSyncResult>.Ok(result);
|
||||
})
|
||||
.On(
|
||||
MoShiroMethods.TestPhase,
|
||||
MoShiroJsonContext.Default.TestPhaseStartParams,
|
||||
MoShiroJsonContext.Default.TestPhaseStartResult,
|
||||
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
|
||||
.On(
|
||||
MoShiroMethods.TestPhaseEnd,
|
||||
MoShiroJsonContext.Default.TestPhaseEndParams,
|
||||
MoShiroJsonContext.Default.TestPhaseEndResult,
|
||||
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
|
||||
.OnNotify(
|
||||
MoShiroMethods.TestAbort,
|
||||
MoShiroJsonContext.Default.TestAbortParams,
|
||||
async (session, abort, _) => await session.AbortAsync(abort.TestId))
|
||||
.OnNotify(
|
||||
FleetMethods.OutboxAck,
|
||||
FleetRpcContext.Default.OutboxAckParams,
|
||||
(session, ack, _) =>
|
||||
{
|
||||
session.Outbox.Acknowledge(ack.Ids.Concat(ack.Duplicates).Select(Guid.Parse));
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 本节点要拨的监听节点列表:由面板作为期望状态(<c>dial_targets</c>)直接下发;面板连不上时用上一次保存的 <c>dial_targets.json</c>。
|
||||
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
|
||||
/// </summary>
|
||||
public sealed class DialDirectory
|
||||
{
|
||||
public const string FileName = "dial_targets.json";
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly ConfigStore _store;
|
||||
private readonly INodeLog _log;
|
||||
private readonly TrustAnchor _trust;
|
||||
private List<DialEndpoint> _targets = [];
|
||||
private long _version;
|
||||
|
||||
public DialDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
|
||||
{
|
||||
_trust = trust ?? TrustAnchor.None;
|
||||
_store = store;
|
||||
_log = log;
|
||||
string cache = Path.Combine(configDirectory, FileName);
|
||||
if (File.Exists(cache))
|
||||
{
|
||||
try
|
||||
{
|
||||
DialCache saved = NodeConfig.Load(cache, AgentConfigContext.Default.DialCache);
|
||||
_targets = saved.Targets;
|
||||
_version = saved.Version;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException)
|
||||
{
|
||||
_log.Warn("dial_targets.json is unreadable, waiting for the panel", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>列表变化(调和连接)。</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
public long Version
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<DialEndpoint> Targets
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return [.. _targets];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
|
||||
public void Apply(DialTargets result)
|
||||
{
|
||||
var next = new List<DialEndpoint>();
|
||||
_trust.Update(result.TrustChain);
|
||||
foreach (DialTarget target in result.Targets)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(target.Host))
|
||||
{
|
||||
next.Add(new DialEndpoint { NodeId = target.NodeId.Value, Host = target.Host, TcpPort = target.TcpPort, QuicPorts = [.. target.QuicPorts], PublicKey = target.PublicKey });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(target.Ipv6Host))
|
||||
{
|
||||
next.Add(new DialEndpoint { NodeId = target.NodeId.Value, Host = target.Ipv6Host, TcpPort = target.TcpPort, QuicPorts = [.. target.QuicPorts], PublicKey = target.PublicKey, IpVersion = IpVersions.V6 });
|
||||
}
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (result.Version < _version || (result.Version == _version && Same(next, _targets)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_version = result.Version;
|
||||
_targets = next;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_store.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new DialCache { Version = result.Version, Targets = next }, AgentConfigContext.Default.DialCache));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Warn("could not save dial_targets.json", ("err", ex.Message));
|
||||
}
|
||||
|
||||
_log.Info("dial targets updated", ("version", result.Version), ("targets", next.Count));
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
private static bool Same(List<DialEndpoint> a, List<DialEndpoint> b) =>
|
||||
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
|
||||
|
||||
public static bool Equal(DialEndpoint a, DialEndpoint b) =>
|
||||
a.NodeId == b.NodeId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>要拨的一个监听节点的一条路径(一个 IP 族):地址、端口和固定证书用的公钥。</summary>
|
||||
public sealed class DialEndpoint
|
||||
{
|
||||
[JsonPropertyName("ip_version")]
|
||||
public string IpVersion { get; set; } = IpVersions.V4;
|
||||
|
||||
[JsonIgnore]
|
||||
public string PathKey => NodeId + "~" + IpVersion;
|
||||
|
||||
[JsonPropertyName("node_id")]
|
||||
public string NodeId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("tcp_port")]
|
||||
public int TcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [7443];
|
||||
|
||||
/// <summary>监听方的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
|
||||
[JsonPropertyName("public_key")]
|
||||
public string PublicKey { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary><c>dial_targets.json</c>:最近一次从面板同步到的拨号目标(配置,不是测量数据);面板连不上时节点靠它继续测量。</summary>
|
||||
public sealed class DialCache
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public long Version { get; set; }
|
||||
|
||||
[JsonPropertyName("targets")]
|
||||
public List<DialEndpoint> Targets { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Fleet.Link;
|
||||
using Fleet.Rpc;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。</summary>
|
||||
public sealed class DialSession
|
||||
{
|
||||
private const int MaxBlocks = 16;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, RunningBlock> _lineBlocks = [];
|
||||
private readonly Dictionary<string, string> _lineKinds = [];
|
||||
private readonly ConcurrentDictionary<uint, PhaseState> _phases = new();
|
||||
|
||||
public DialSession(BlockEnv blockEnv, ClockEstimator clock, Outbox outbox, DialEnv env)
|
||||
{
|
||||
BlockEnv = blockEnv;
|
||||
Clock = clock;
|
||||
Outbox = outbox;
|
||||
Env = env;
|
||||
}
|
||||
|
||||
public BlockEnv BlockEnv { get; }
|
||||
|
||||
public ClockEstimator Clock { get; }
|
||||
|
||||
public Outbox Outbox { get; }
|
||||
|
||||
public DialEnv Env { get; }
|
||||
|
||||
public int RoleCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _lineBlocks.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>正在运行的 TCP 类线路积木数(它们自带套接字和重拨,不依赖 QUIC 数据通道)。</summary>
|
||||
public int TcpBlockCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _lineKinds.Values.Count(IsTcpKind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTcpKind(string kind) => kind is "tcp_ping" or "tcp_connect" or "tls_hold" or "tcp_transfer";
|
||||
|
||||
/// <summary>调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。</summary>
|
||||
public async Task<List<RejectedItem>> ApplyAsync(LineTaskSet set)
|
||||
{
|
||||
var rejected = new List<RejectedItem>();
|
||||
var wanted = new Dictionary<string, BlockSpec>();
|
||||
foreach (LineTask task in set.Tasks)
|
||||
{
|
||||
BlockSpec? spec = BlockCatalog.DialerKinds.Contains(task.Kind)
|
||||
? BlockSpec.Create(task.Kind, task.Params, task.StreamId, task.Port, new FragmentScope(task.Id, null, null))
|
||||
: null;
|
||||
if (spec is null)
|
||||
{
|
||||
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = BlockCatalog.DialerKinds.Contains(task.Kind) ? "invalid_params" : "unsupported" });
|
||||
}
|
||||
else if (wanted.Count >= MaxBlocks)
|
||||
{
|
||||
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = "budget_exceeded" });
|
||||
}
|
||||
else
|
||||
{
|
||||
wanted[task.Id.Value] = spec;
|
||||
}
|
||||
}
|
||||
|
||||
var stopped = new List<RunningBlock>();
|
||||
lock (_gate)
|
||||
{
|
||||
foreach ((string id, RunningBlock running) in _lineBlocks.ToList())
|
||||
{
|
||||
if (!wanted.TryGetValue(id, out BlockSpec? spec) || spec.Signature != running.Signature)
|
||||
{
|
||||
stopped.Add(running);
|
||||
_lineBlocks.Remove(id);
|
||||
_lineKinds.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((string id, BlockSpec spec) in wanted)
|
||||
{
|
||||
if (!_lineBlocks.ContainsKey(id))
|
||||
{
|
||||
_lineBlocks[id] = RunningBlock.Start(BlockCatalog.Find(spec.KindId)!.Dialer(BlockEnv, spec), spec.Signature);
|
||||
_lineKinds[id] = spec.KindId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 被停止的积木把已经稳定的行交出去(刚发出、还在等回复的行不交:它们不是丢失,只是没有等到)
|
||||
foreach (RunningBlock running in stopped)
|
||||
{
|
||||
await running.StopAsync();
|
||||
running.Block.Flush(FlushMode.Settled);
|
||||
}
|
||||
|
||||
return rejected;
|
||||
}
|
||||
|
||||
public void FlushLineBlocks()
|
||||
{
|
||||
List<RunningBlock> blocks;
|
||||
lock (_gate)
|
||||
{
|
||||
blocks = [.. _lineBlocks.Values];
|
||||
}
|
||||
|
||||
foreach (RunningBlock running in blocks)
|
||||
{
|
||||
running.Block.Flush(FlushMode.Settled);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。</summary>
|
||||
public bool StartPhase(TestPhaseStartParams start)
|
||||
{
|
||||
string? direction = start.Params.TryGetValue("direction", out System.Text.Json.JsonElement d) ? d.GetString() : null;
|
||||
if (_phases.ContainsKey(start.StreamId) || !BlockCatalog.DialerKinds.Contains(start.Kind) || BlockCatalog.Find(start.Kind)!.Supports(direction) is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (start.Kind == "quic_stream")
|
||||
{
|
||||
double mbps = start.Params["mbps"].GetDouble();
|
||||
long size = start.Params["size"].GetInt64();
|
||||
long pps = (long)(mbps * 1_000_000 / 8 / Math.Max(1, size));
|
||||
if (mbps > Env.Limits.MaxTotalMbps || pps > Env.Limits.MaxTotalPps)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
BlockSpec? spec = BlockSpec.Create(start.Kind, start.Params, start.StreamId, start.Port, new FragmentScope(null, start.TestId, start.Phase));
|
||||
if (spec is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_phases[start.StreamId] = new PhaseState(start.TestId, start.Kind, RunningBlock.Start(BlockCatalog.Find(start.Kind)!.Dialer(BlockEnv, spec), spec.Signature));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>阶段结束:收尾、交出列块,返回块数。</summary>
|
||||
public async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
|
||||
{
|
||||
if (!_phases.TryRemove(end.StreamId, out PhaseState? phase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "quic_stream" ? 200 : 300), BlockEnv.Time, ct);
|
||||
await phase.Running.StopAsync();
|
||||
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
|
||||
}
|
||||
|
||||
public async Task AbortAsync(TestId test)
|
||||
{
|
||||
foreach ((uint stream, PhaseState phase) in _phases.ToList())
|
||||
{
|
||||
if (phase.Test == test && _phases.TryRemove(stream, out _))
|
||||
{
|
||||
await phase.Running.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAllAsync()
|
||||
{
|
||||
List<RunningBlock> blocks;
|
||||
lock (_gate)
|
||||
{
|
||||
blocks = [.. _lineBlocks.Values];
|
||||
_lineBlocks.Clear();
|
||||
_lineKinds.Clear();
|
||||
}
|
||||
|
||||
blocks.AddRange(_phases.Values.Select(p => p.Running));
|
||||
_phases.Clear();
|
||||
foreach (RunningBlock running in blocks)
|
||||
{
|
||||
await running.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PhaseState(TestId Test, string Kind, RunningBlock Running);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 拨号侧的组装点:每个拨号目标(监听节点 × IP 族)一个 <see cref="DialConnection"/>。目标列表变化时只调和连接:
|
||||
/// 新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
|
||||
/// </summary>
|
||||
public sealed class DialerHost : IDisposable
|
||||
{
|
||||
private readonly DialEnv _env;
|
||||
private readonly Dictionary<string, Running> _connections = [];
|
||||
private readonly ConcurrentDictionary<string, long> _pendingRetries = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _changed = new(0);
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
|
||||
public DialerHost(DialEnv env, TimeProvider time, INodeLog log)
|
||||
{
|
||||
_env = env;
|
||||
_time = time;
|
||||
_log = log;
|
||||
env.Directory.Changed += () => _changed.Release();
|
||||
}
|
||||
|
||||
public DialDirectory Directory => _env.Directory;
|
||||
|
||||
/// <summary>某条路径的控制会话是否已经建立、QUIC 数据通道是否可发送(测试和状态查看用)。</summary>
|
||||
public bool IsConnected(string listenerId, string ipVersion = IpVersions.V4)
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
return _connections.TryGetValue(listenerId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected;
|
||||
}
|
||||
}
|
||||
|
||||
public DialConnection? Connection(string listenerId, string ipVersion = IpVersions.V4)
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
return _connections.TryGetValue(listenerId + "~" + ipVersion, out Running? running) ? running.Connection : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>每条路径当前的线路健康(随心跳上报)。</summary>
|
||||
public List<LinkHealth> SnapshotLinks()
|
||||
{
|
||||
DialConnection[] connections;
|
||||
lock (_connections)
|
||||
{
|
||||
connections = [.. _connections.Values.Select(r => r.Connection)];
|
||||
}
|
||||
|
||||
return [.. connections.Select(c => c.Health())];
|
||||
}
|
||||
|
||||
/// <summary>管理端手动重试:跳过这个监听节点所有路径的退避。目标列表还没到时,30 秒内到达就补上。</summary>
|
||||
public void RetryListener(string listenerId, bool queueIfMissing = true)
|
||||
{
|
||||
DialConnection[] matches;
|
||||
lock (_connections)
|
||||
{
|
||||
matches = [.. _connections.Values.Select(r => r.Connection).Where(c => c.Endpoint.NodeId == listenerId)];
|
||||
}
|
||||
|
||||
if (matches.Length == 0)
|
||||
{
|
||||
if (queueIfMissing)
|
||||
{
|
||||
_pendingRetries[listenerId] = _time.GetUtcNow().AddSeconds(30).ToUnixTimeMilliseconds();
|
||||
}
|
||||
|
||||
_log.Warn("manual link retry requested but no matching dial target is configured", ("listener", listenerId));
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRetries.TryRemove(listenerId, out _);
|
||||
foreach (DialConnection connection in matches)
|
||||
{
|
||||
connection.RetryNow();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await ReconcileAsync(_env.Directory.Targets, ct);
|
||||
RetryPending();
|
||||
await _changed.WaitAsync(ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 停止
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ReconcileAsync([], CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _changed.Dispose();
|
||||
|
||||
private void RetryPending()
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
foreach ((string listenerId, long expiresAt) in _pendingRetries.ToArray())
|
||||
{
|
||||
if (expiresAt <= now)
|
||||
{
|
||||
_pendingRetries.TryRemove(listenerId, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
RetryListener(listenerId, queueIfMissing: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReconcileAsync(IReadOnlyList<DialEndpoint> targets, CancellationToken ct)
|
||||
{
|
||||
var wanted = targets.ToDictionary(t => t.PathKey);
|
||||
var stopped = new List<Running>();
|
||||
lock (_connections)
|
||||
{
|
||||
foreach ((string id, Running running) in _connections.ToList())
|
||||
{
|
||||
if (!wanted.TryGetValue(id, out DialEndpoint? endpoint) || !DialDirectory.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, DialEndpoint endpoint) in wanted)
|
||||
{
|
||||
if (!_connections.ContainsKey(id))
|
||||
{
|
||||
_connections[id] = Running.Start(new DialConnection(endpoint, _env));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Running : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task _run = Task.CompletedTask;
|
||||
|
||||
private Running(DialConnection connection)
|
||||
{
|
||||
Connection = connection;
|
||||
}
|
||||
|
||||
public DialConnection Connection { get; }
|
||||
|
||||
public static Running Start(DialConnection 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()
|
||||
{
|
||||
Connection.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>控制连接在已建立之后停止应答:对端不认这个会话了,或者线路中断超过了空闲上限。</summary>
|
||||
public sealed class ControlSilentException(string message) : IOException(message);
|
||||
|
||||
/// <summary>把连接各阶段的异常归类成上报给面板的 <see cref="LinkFault"/>:失败发生在哪个阶段、属于哪一类。</summary>
|
||||
public static class LinkFaults
|
||||
{
|
||||
private const int MaxMessage = 200;
|
||||
|
||||
public static LinkFault From(Exception ex, string phase)
|
||||
{
|
||||
Exception inner = ex is AggregateException { InnerExceptions.Count: 1 } aggregate ? aggregate.InnerExceptions[0] : ex;
|
||||
string kind = inner switch
|
||||
{
|
||||
ControlSilentException => "silent",
|
||||
OperationCanceledException or TimeoutException => "timeout",
|
||||
AuthenticationException => "tls",
|
||||
SocketException socket => Kind(socket.SocketErrorCode),
|
||||
IOException { InnerException: SocketException socket } => Kind(socket.SocketErrorCode),
|
||||
InvalidOperationException invalid when invalid.Message.Contains("authentication", StringComparison.OrdinalIgnoreCase)
|
||||
|| invalid.Message.Contains("challenge", StringComparison.OrdinalIgnoreCase) => "auth",
|
||||
IOException => "closed",
|
||||
_ => "other",
|
||||
};
|
||||
string message = kind == "timeout" && inner is OperationCanceledException ? $"{phase} did not finish in time" : inner.Message;
|
||||
return new LinkFault { Phase = phase, Kind = kind, Message = message.Length > MaxMessage ? message[..MaxMessage] : message };
|
||||
}
|
||||
|
||||
public static LinkFault Closed(string phase, string message) => new() { Phase = phase, Kind = "closed", Message = message };
|
||||
|
||||
private static string Kind(SocketError error) => error switch
|
||||
{
|
||||
SocketError.ConnectionRefused => "refused",
|
||||
SocketError.ConnectionReset or SocketError.ConnectionAborted => "reset",
|
||||
SocketError.HostNotFound or SocketError.NoData or SocketError.TryAgain => "dns",
|
||||
SocketError.TimedOut => "timeout",
|
||||
_ => "other",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Link;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 一条线路(本节点拨号到一个监听节点的一个 IP 族)的状态:控制层和 QUIC 数据通道各一个状态机,状态迁移只在这里发生、只在这里记日志。
|
||||
/// 控制层每次迁移写一条 <c>link state</c>;数据通道只在“可用 ↔ 不可用”的边界上写,重拨过程中的失败只计数。
|
||||
/// </summary>
|
||||
public sealed class LinkStatus
|
||||
{
|
||||
private const int MaxHandshakeSamples = 128;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly string _listenerId;
|
||||
private readonly string _ipVersion;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Queue<double> _handshakeMs = new();
|
||||
|
||||
private ControlState _control = ControlState.Dialing;
|
||||
private long _controlSince;
|
||||
private LinkFault? _controlFault;
|
||||
private int _controlAttempt;
|
||||
private long? _controlRetryAt;
|
||||
|
||||
private QuicState _quic = QuicState.Idle;
|
||||
private long _quicSince;
|
||||
private long? _quicDownSince;
|
||||
private LinkFault? _quicFault;
|
||||
private int _quicAttempt;
|
||||
private long? _quicRetryAt;
|
||||
|
||||
private long _handshakeAttempts;
|
||||
private long _handshakeSuccesses;
|
||||
private long _handshakeFailures;
|
||||
private long _handshakeTimeouts;
|
||||
|
||||
public LinkStatus(string listenerId, string ipVersion, TimeProvider time, INodeLog log)
|
||||
{
|
||||
_listenerId = listenerId;
|
||||
_ipVersion = ipVersion;
|
||||
_time = time;
|
||||
Log = log;
|
||||
_controlSince = _quicSince = Now();
|
||||
}
|
||||
|
||||
/// <summary>当前这次尝试的日志(带 attempt_id 等上下文);控制层每次重拨前由连接更新。</summary>
|
||||
public INodeLog Log { get; set; }
|
||||
|
||||
public ControlState Control
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _control;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public QuicState Quic
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _quic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool Ready => Control == ControlState.Up && Quic == QuicState.Ready;
|
||||
|
||||
public void SetControl(ControlState state, LinkFault? fault = null, int attempt = 0, TimeSpan? retryIn = null)
|
||||
{
|
||||
ControlState from;
|
||||
long inStateMs;
|
||||
lock (_gate)
|
||||
{
|
||||
from = _control;
|
||||
long now = Now();
|
||||
inStateMs = now - _controlSince;
|
||||
if (state != from)
|
||||
{
|
||||
_controlSince = now;
|
||||
}
|
||||
|
||||
_control = state;
|
||||
_controlFault = state == ControlState.Up ? null : fault ?? _controlFault;
|
||||
_controlAttempt = attempt;
|
||||
_controlRetryAt = retryIn is { } delay ? now + (long)delay.TotalMilliseconds : null;
|
||||
}
|
||||
|
||||
if (state == from)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string reason = state == ControlState.Backoff && fault is not null ? fault.ToString() : "";
|
||||
Log.Write(state == ControlState.Backoff ? NodeLogLevel.Warn : NodeLogLevel.Info, "link state",
|
||||
[
|
||||
("layer", "control"), ("listener", _listenerId), ("ip_version", _ipVersion), ("from", Name(from)), ("to", Name(state)),
|
||||
("in_state_ms", inStateMs), ("attempt", attempt), ("retry_in_ms", retryIn is { } r ? (long)r.TotalMilliseconds : null),
|
||||
("reason", reason.Length == 0 ? null : reason),
|
||||
]);
|
||||
}
|
||||
|
||||
public void SetQuic(QuicState state, LinkFault? fault = null, int attempt = 0, TimeSpan? retryIn = null)
|
||||
{
|
||||
QuicState from;
|
||||
long? downMs = null;
|
||||
bool logged;
|
||||
lock (_gate)
|
||||
{
|
||||
from = _quic;
|
||||
long now = Now();
|
||||
if (state != from)
|
||||
{
|
||||
_quicSince = now;
|
||||
}
|
||||
|
||||
if (state == QuicState.Ready)
|
||||
{
|
||||
downMs = _quicDownSince is { } down ? now - down : null;
|
||||
_quicDownSince = null;
|
||||
_quicFault = null;
|
||||
}
|
||||
else if (state is QuicState.Retrying or QuicState.Connecting)
|
||||
{
|
||||
_quicDownSince ??= now;
|
||||
_quicFault = fault ?? _quicFault;
|
||||
}
|
||||
else
|
||||
{
|
||||
_quicDownSince = null;
|
||||
}
|
||||
|
||||
// 数据通道抖动时不逐次打印:恢复可用、首次失败时各一条,重拨中的失败只计数。
|
||||
logged = state == QuicState.Ready || (state == QuicState.Retrying && (from == QuicState.Ready || attempt == 1));
|
||||
_quic = state;
|
||||
_quicAttempt = attempt;
|
||||
_quicRetryAt = retryIn is { } delay ? now + (long)delay.TotalMilliseconds : null;
|
||||
}
|
||||
|
||||
if (logged)
|
||||
{
|
||||
Log.Write(state == QuicState.Retrying ? NodeLogLevel.Warn : NodeLogLevel.Info, "link state",
|
||||
[
|
||||
("layer", "task_quic"), ("listener", _listenerId), ("ip_version", _ipVersion), ("from", Name(from)), ("to", Name(state)),
|
||||
("down_ms", downMs), ("attempt", attempt), ("retry_in_ms", retryIn is { } r ? (long)r.TotalMilliseconds : null),
|
||||
("reason", state == QuicState.Retrying ? fault?.ToString() : null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>记录一次 QUIC 握手的结果。</summary>
|
||||
public void Handshake(bool succeeded, double? elapsedMs, bool timedOut)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_handshakeAttempts++;
|
||||
if (succeeded)
|
||||
{
|
||||
_handshakeSuccesses++;
|
||||
if (elapsedMs is { } ms)
|
||||
{
|
||||
_handshakeMs.Enqueue(ms);
|
||||
while (_handshakeMs.Count > MaxHandshakeSamples)
|
||||
{
|
||||
_handshakeMs.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_handshakeFailures++;
|
||||
if (timedOut)
|
||||
{
|
||||
_handshakeTimeouts++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LinkHealth Snapshot(Outbox outbox, int tcpBlocks, QuicTransportSnapshot? transport)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
long now = Now();
|
||||
double? p95 = null;
|
||||
if (_handshakeMs.Count > 0)
|
||||
{
|
||||
double[] sorted = [.. _handshakeMs.Order()];
|
||||
p95 = sorted[(int)Math.Ceiling(sorted.Length * 0.95) - 1];
|
||||
}
|
||||
|
||||
return new LinkHealth
|
||||
{
|
||||
ListenerId = _listenerId,
|
||||
IpVersion = _ipVersion,
|
||||
Control = new ControlHealth
|
||||
{
|
||||
State = _control,
|
||||
SinceMs = _controlSince,
|
||||
LastError = _controlFault,
|
||||
RetryAttempt = _controlAttempt,
|
||||
RetryInMs = _control == ControlState.Backoff ? RetryIn(_controlRetryAt, now) : null,
|
||||
OutboxBytes = outbox.PendingBytes,
|
||||
OutboxPending = outbox.PendingCount,
|
||||
},
|
||||
Task = new TaskHealth
|
||||
{
|
||||
TcpBlocks = tcpBlocks,
|
||||
Quic = new QuicHealth
|
||||
{
|
||||
State = _quic,
|
||||
SinceMs = _quicSince,
|
||||
LastError = _quicFault,
|
||||
RetryAttempt = _quicAttempt,
|
||||
RetryInMs = _quic == QuicState.Retrying ? RetryIn(_quicRetryAt, now) : null,
|
||||
HandshakeAttempts = _handshakeAttempts,
|
||||
HandshakeSuccesses = _handshakeSuccesses,
|
||||
HandshakeFailures = _handshakeFailures,
|
||||
HandshakeTimeouts = _handshakeTimeouts,
|
||||
HandshakeP95Ms = p95,
|
||||
HandshakeSampleCount = _handshakeMs.Count,
|
||||
TransportPacketLossRate = transport?.PacketLossRate,
|
||||
TransportPacketsSent = transport?.PacketsSent,
|
||||
TransportCongestionEvents = transport?.CongestionEvents,
|
||||
TransportEcnCongestionEvents = transport?.EcnCongestionEvents,
|
||||
PathMtu = transport?.PathMtu,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static long? RetryIn(long? at, long now) => at is { } t ? Math.Max(0, t - now) : null;
|
||||
|
||||
private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
|
||||
private static string Name(ControlState state) => state.ToString().ToLowerInvariant();
|
||||
|
||||
private static string Name(QuicState state) => state.ToString().ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// QUIC 数据通道的重拨节奏:1、2、3、5 秒,之后固定 5 秒。数据通道断开期间的测量是缺口,所以不像控制连接那样拉长到一分钟。
|
||||
/// 抖动由调用方通过 <paramref name="random"/> 给出,避免多条线路同时重拨。
|
||||
/// </summary>
|
||||
public sealed class QuicRedialBackoff
|
||||
{
|
||||
private static readonly TimeSpan[] s_delays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5)];
|
||||
|
||||
private const int ShortAttempts = 12;
|
||||
|
||||
private int _attempt;
|
||||
|
||||
public int Attempts => _attempt;
|
||||
|
||||
public TimeSpan Next(Random? random = null)
|
||||
{
|
||||
TimeSpan delay = s_delays[Math.Min(_attempt, s_delays.Length - 1)];
|
||||
if (_attempt >= ShortAttempts)
|
||||
{
|
||||
// 短间隔已经连续失败了这么多次:这条路径大概率是死的(对端不通、被丢弃),握手和日志都是白费,翻倍退避,最长五分钟
|
||||
delay = TimeSpan.FromSeconds(Math.Min(300, 5 * (1 << Math.Min(_attempt - ShortAttempts + 1, 6))));
|
||||
}
|
||||
|
||||
_attempt++;
|
||||
return random is null ? delay : delay * (0.8 + (0.4 * random.NextDouble()));
|
||||
}
|
||||
|
||||
public void Reset() => _attempt = 0;
|
||||
}
|
||||
+14
-14
@@ -4,11 +4,11 @@ using Fleet.Link;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Probe;
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>moshiro.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
|
||||
/// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
|
||||
/// 估计监听方单调时钟 − 拨号方单调时钟:每次 <c>moshiro.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
|
||||
/// (往返越短,对称性假设的误差越小)。拨号方的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
|
||||
/// </summary>
|
||||
public sealed class ClockEstimator
|
||||
{
|
||||
@@ -21,11 +21,11 @@ public sealed class ClockEstimator
|
||||
|
||||
public bool HasEstimate { get; private set; }
|
||||
|
||||
public void Add(long sendUs, long agentUs, long receiveUs)
|
||||
public void Add(long sendUs, long listenerUs, long receiveUs)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_samples.Enqueue((receiveUs - sendUs, agentUs - ((sendUs + receiveUs) / 2)));
|
||||
_samples.Enqueue((receiveUs - sendUs, listenerUs - ((sendUs + receiveUs) / 2)));
|
||||
while (_samples.Count > Window)
|
||||
{
|
||||
_samples.Dequeue();
|
||||
@@ -38,22 +38,22 @@ public sealed class ClockEstimator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把 Probe 一侧的行交给这个 Agent:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。
|
||||
/// 把拨号方一侧的行交给监听方:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。
|
||||
/// 发件箱的内容在连接断开重连之后重发;发件箱满了丢弃最旧的。
|
||||
/// </summary>
|
||||
public sealed class FragmentShipper : IFragmentSink
|
||||
{
|
||||
private readonly Outbox _outbox;
|
||||
private readonly NodeId _agent;
|
||||
private readonly ProbeId _probe;
|
||||
private readonly NodeId _listener;
|
||||
private readonly NodeId _dialer;
|
||||
private readonly ClockEstimator _clock;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public FragmentShipper(Outbox outbox, NodeId agent, ProbeId probe, ClockEstimator clock, TimeProvider time)
|
||||
public FragmentShipper(Outbox outbox, NodeId listener, NodeId dialer, ClockEstimator clock, TimeProvider time)
|
||||
{
|
||||
_outbox = outbox;
|
||||
_agent = agent;
|
||||
_probe = probe;
|
||||
_listener = listener;
|
||||
_dialer = dialer;
|
||||
_clock = clock;
|
||||
_time = time;
|
||||
}
|
||||
@@ -67,12 +67,12 @@ public sealed class FragmentShipper : IFragmentSink
|
||||
TaskId = scope.Task,
|
||||
TestId = scope.Test,
|
||||
Phase = scope.Phase,
|
||||
AgentId = _agent,
|
||||
ProbeId = _probe,
|
||||
AgentId = _listener,
|
||||
DialerId = _dialer,
|
||||
Port = port,
|
||||
StartUtcMs = startUtcMs,
|
||||
DurationMs = Math.Max(1, durationMs),
|
||||
Clock = new ChunkClock { ProbeOffsetUs = _clock.OffsetUs },
|
||||
Clock = new ChunkClock { DialerOffsetUs = _clock.OffsetUs },
|
||||
};
|
||||
if (parameters is not null)
|
||||
{
|
||||
@@ -0,0 +1,162 @@
|
||||
using Fleet.Core;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 任务层的 QUIC 数据通道:控制层在线期间在后台维持一条 QUIC 连接,断开后按 <see cref="QuicRedialBackoff"/> 重拨。
|
||||
/// 重拨期间控制连接和 TCP 类积木不受影响;UDP 类积木的发送在通道不可用时按各自的规则处理。
|
||||
/// </summary>
|
||||
public sealed class TaskLink : IDisposable
|
||||
{
|
||||
/// <summary>QUIC 连接可用至少这么久才算稳定,重拨节奏随之复位。刚连上就断的通道不会把间隔重置回 1 秒。</summary>
|
||||
private static readonly TimeSpan s_stableAfter = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>一直在发测量报文、却这么久没有收到任何一个:QUIC 路径已经是黑洞,主动关闭重拨。</summary>
|
||||
private static readonly TimeSpan s_silentLimit = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly TimeSpan s_watchInterval = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan s_summaryInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly QuicDatagramTransport _datagrams;
|
||||
private readonly LinkStatus _status;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SemaphoreSlim _retrySignal = new(0, 1);
|
||||
|
||||
public TaskLink(QuicDatagramTransport datagrams, LinkStatus status, TimeProvider time)
|
||||
{
|
||||
_datagrams = datagrams;
|
||||
_status = status;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public void Dispose() => _retrySignal.Dispose();
|
||||
|
||||
/// <summary>管理端手动重试:跳过当前的重拨等待。</summary>
|
||||
public void RetryNow()
|
||||
{
|
||||
try
|
||||
{
|
||||
_retrySignal.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
// 已有一次排队
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = new QuicRedialBackoff();
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
_status.SetQuic(QuicState.Connecting, attempt: backoff.Attempts);
|
||||
LinkFault fault;
|
||||
long readyAt = 0;
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await _datagrams.ConnectAsync(ct);
|
||||
}
|
||||
catch (Exception ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_status.Handshake(false, null, ex is TimeoutException or OperationCanceledException);
|
||||
throw;
|
||||
}
|
||||
|
||||
_status.Handshake(true, _datagrams.HandshakeDurationMs, false);
|
||||
readyAt = _time.GetTimestamp();
|
||||
_status.SetQuic(QuicState.Ready);
|
||||
fault = await WatchAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
fault = LinkFaults.From(ex, "quic_handshake");
|
||||
}
|
||||
|
||||
await _datagrams.CloseClientAsync();
|
||||
if (readyAt != 0 && _time.GetElapsedTime(readyAt) >= s_stableAfter)
|
||||
{
|
||||
backoff.Reset();
|
||||
}
|
||||
|
||||
TimeSpan delay = backoff.Next(Random.Shared);
|
||||
_status.SetQuic(QuicState.Retrying, fault, backoff.Attempts, delay);
|
||||
try
|
||||
{
|
||||
using var wait = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task sleep = Task.Delay(delay, _time, wait.Token);
|
||||
Task manual = _retrySignal.WaitAsync(wait.Token);
|
||||
await Task.WhenAny(sleep, manual);
|
||||
await wait.CancelAsync();
|
||||
await Task.WhenAll(Settle(sleep), Settle(manual));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>连接可用期间的看护:连接关闭(对端关闭、空闲超时)或数据面静默时返回原因。</summary>
|
||||
private async Task<LinkFault> WatchAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(s_watchInterval, _time);
|
||||
Task closed = _datagrams.WaitForDisconnectAsync(ct);
|
||||
long seenIn = _datagrams.Counters.PacketsIn;
|
||||
long seenOut = _datagrams.Counters.PacketsOut;
|
||||
long inChangedAt = _time.GetTimestamp();
|
||||
long lastSummaryAt = inChangedAt;
|
||||
while (true)
|
||||
{
|
||||
Task tick = timer.WaitForNextTickAsync(ct).AsTask();
|
||||
if (await Task.WhenAny(closed, tick) == closed)
|
||||
{
|
||||
await closed;
|
||||
return LinkFaults.Closed("quic_data", "QUIC connection closed");
|
||||
}
|
||||
|
||||
await tick;
|
||||
long packetsIn = _datagrams.Counters.PacketsIn;
|
||||
long packetsOut = _datagrams.Counters.PacketsOut;
|
||||
long now = _time.GetTimestamp();
|
||||
if (packetsIn != seenIn)
|
||||
{
|
||||
seenIn = packetsIn;
|
||||
inChangedAt = now;
|
||||
}
|
||||
else if (packetsOut != seenOut && _time.GetElapsedTime(inChangedAt, now) > s_silentLimit)
|
||||
{
|
||||
return new LinkFault { Phase = "quic_data", Kind = "silent", Message = $"no datagram received for {(long)_time.GetElapsedTime(inChangedAt, now).TotalSeconds}s while sending" };
|
||||
}
|
||||
|
||||
seenOut = packetsOut;
|
||||
if (_time.GetElapsedTime(lastSummaryAt, now) >= s_summaryInterval)
|
||||
{
|
||||
lastSummaryAt = now;
|
||||
QuicTransportSnapshot? transport = _datagrams.GetTransportSnapshot();
|
||||
_status.Log.Info("agent measurement summary", ("layer", "task_quic"), ("packets_in", packetsIn), ("packets_out", packetsOut),
|
||||
("dropped_invalid", _datagrams.Counters.DroppedInvalid), ("dropped_rate_limited", _datagrams.Counters.DroppedRateLimited),
|
||||
("transport_packets_sent", transport?.PacketsSent), ("path_mtu", transport?.PathMtu));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Settle(Task task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 等待被另一条路径或停止打断
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,6 @@
|
||||
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
|
||||
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
|
||||
Condition="'$(UseAotAnywhere)' == 'true'" />
|
||||
<Import Project="..\..\..\shared\MoShiro.Measurement\StaticMsQuic.targets"
|
||||
Condition="'$(StaticMsQuic)' == 'true'" />
|
||||
</Project>
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Native supervisor bridge: writes only fixed wake-up flags and a health beacon in tmpfs.
|
||||
/// Never downloads, installs, verifies releases, or executes commands. The root-owned host
|
||||
/// updater independently fetches and verifies its locally configured release feed.
|
||||
/// </summary>
|
||||
public sealed class OpenWrtUpdater
|
||||
{
|
||||
private readonly ConfigStore _runtime;
|
||||
private readonly string _statusPath;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
|
||||
public OpenWrtUpdater(string runtimeDirectory, string statusPath, TimeProvider time, INodeLog log)
|
||||
{
|
||||
_runtime = new ConfigStore(runtimeDirectory);
|
||||
_statusPath = statusPath;
|
||||
_time = time;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public static OpenWrtUpdater? FromEnvironment(TimeProvider time, INodeLog log) =>
|
||||
Environment.GetEnvironmentVariable(RestartUpdater.EnvironmentVariable) == "openwrt"
|
||||
? new OpenWrtUpdater("/var/run/moshiro-agent", "/var/run/moshiro-update/status.json", time, log)
|
||||
: null;
|
||||
|
||||
public void Offer() => Signal("check.request");
|
||||
|
||||
public void RequestApply() => Signal("apply.request");
|
||||
|
||||
private void Signal(string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
_runtime.Write(fileName, "check"u8.ToArray());
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Warn("could not signal the native updater", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called by the Fleet heartbeat: evidence that this version reached Backend.</summary>
|
||||
public UpdateStatus? Status()
|
||||
{
|
||||
try
|
||||
{
|
||||
var health = new OpenWrtHealth(BuildInfo.Version, Environment.ProcessId, _time.GetUtcNow().ToUnixTimeSeconds());
|
||||
_runtime.Write("health.json", JsonSerializer.SerializeToUtf8Bytes(health, AgentConfigContext.Default.OpenWrtHealth));
|
||||
if (!File.Exists(_statusPath) || new FileInfo(_statusPath).Length > 8192)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(File.ReadAllBytes(_statusPath), MoShiroJsonContext.Default.UpdateStatus);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
_log.Warn("could not read native update status", ("err", ex.Message));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OpenWrtHealth(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("pid")] int Pid,
|
||||
[property: JsonPropertyName("at")] long At);
|
||||
@@ -22,7 +22,7 @@ public sealed class BackendUplink
|
||||
|
||||
public bool IsConnected => _link?.CurrentSession is not null;
|
||||
|
||||
public async ValueTask EmitEventAsync(string kind, string? detail = null, ConnectedProbe? probe = null)
|
||||
public async ValueTask EmitEventAsync(string kind, string? detail = null, ConnectedDialer? dialer = null)
|
||||
{
|
||||
RpcSession<LinkContext>? session = _link?.CurrentSession;
|
||||
if (session is null)
|
||||
@@ -30,7 +30,7 @@ public sealed class BackendUplink
|
||||
return;
|
||||
}
|
||||
|
||||
var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Probe = probe };
|
||||
var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Dialer = dialer };
|
||||
await SafeAsync(() => session.NotifyAsync(MoShiroMethods.Event, parameters, MoShiroJsonContext.Default.AgentEventParams, CancellationToken.None));
|
||||
}
|
||||
|
||||
@@ -71,15 +71,16 @@ public sealed class BackendUplink
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe 的点对点会话:每次握手成功之后登记会话(同一个 Probe 的新会话取代它自己的旧会话,不同 Probe 的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收 Probe 的列块并交给
|
||||
/// <see cref="ChunkAssembler"/>。会话结束时通知 Backend,并让正在运行的测试以 <c>probe_offline</c> 结束。
|
||||
/// 拨号方 的点对点会话:每次握手成功之后登记会话(同一个拨号方的新会话取代它自己的旧会话,不同拨号方的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收拨号方的列块并交给
|
||||
/// <see cref="ChunkAssembler"/>。会话结束时通知 Backend,并让正在运行的测试以 <c>dialer_offline</c> 结束。
|
||||
/// </summary>
|
||||
public sealed class PeerHub
|
||||
{
|
||||
private const int DedupeWindow = 1024;
|
||||
|
||||
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。</summary>
|
||||
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(75);
|
||||
/// <summary>超过这么久没有收到拨号方的任何帧就认为会话已死(拨号方 每 30 秒至少发一次时钟同步)。
|
||||
/// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。</summary>
|
||||
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180);
|
||||
|
||||
private readonly RpcRouter<PeerContext> _router;
|
||||
private readonly SessionRegistry _sessions;
|
||||
@@ -105,14 +106,15 @@ public sealed class PeerHub
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>Probe 会话断开(这个 Probe 上的测试据此中止)。</summary>
|
||||
public Action<ProbeSession>? OnProbeLost { get; set; }
|
||||
/// <summary>拨号方 会话断开(这个拨号方上的测试据此中止)。</summary>
|
||||
public Action<DialerSession>? OnDialerLost { get; set; }
|
||||
|
||||
public async Task OnSessionAsync(PeerSessionInfo info, IRpcTransport transport, CancellationToken ct)
|
||||
{
|
||||
var probe = new ProbeSession(info) { ConnectedAtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
|
||||
var rpc = new RpcSession<PeerContext>(transport, _router, new PeerContext(probe, _clock), _time);
|
||||
probe.Rpc = rpc;
|
||||
var dialer = new DialerSession(info) { ConnectedAtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
|
||||
var log = new ContextNodeLog(_log, ("dialer", dialer.DialerId), ("ip_version", dialer.IpVersion), ("control_id", Guid.NewGuid().ToString("N")));
|
||||
var rpc = new RpcSession<PeerContext>(transport, _router, new PeerContext(dialer, _clock), _time);
|
||||
dialer.Rpc = rpc;
|
||||
|
||||
var seen = new HashSet<Guid>();
|
||||
var order = new Queue<Guid>();
|
||||
@@ -135,7 +137,7 @@ public sealed class PeerHub
|
||||
Result<RawChunk> decoded = RawChunkCodec.Decode(payload.Span);
|
||||
if (decoded.TryGet(out RawChunk? fragment, out FleetError? error))
|
||||
{
|
||||
_chunks.Accept(fragment, probe);
|
||||
_chunks.Accept(fragment, dialer);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -147,21 +149,23 @@ public sealed class PeerHub
|
||||
await rpc.NotifyAsync(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams, token);
|
||||
};
|
||||
|
||||
ProbeSession? previous = _sessions.Replace(probe);
|
||||
DialerSession? previous = _sessions.Replace(dialer);
|
||||
if (previous?.Rpc is { } old)
|
||||
{
|
||||
previous.CloseReason = "replaced";
|
||||
log.Info("replacing previous dialer control session");
|
||||
await old.CloseAsync("replaced", CancellationToken.None);
|
||||
}
|
||||
|
||||
// 这个会话自己的一组流:序号从 0 开始,上一个会话留下的记录不会算到它头上
|
||||
LineTaskSet initial = _tasks.OpenSession(probe);
|
||||
LineTaskSet initial = _tasks.OpenSession(dialer);
|
||||
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task run = rpc.RunAsync(linked.Token);
|
||||
Task watchdog = WatchIdleAsync(probe, rpc, linked.Token);
|
||||
_log.Info("probe connected", ("probe", info.PeerId), ("version", info.Version ?? ""), ("remote", probe.RemoteAddress ?? ""));
|
||||
await _uplink.EmitEventAsync(AgentEventKinds.ProbeConnected, info.PeerId, Describe(probe));
|
||||
await PushAsync(probe, initial, linked.Token);
|
||||
Task watchdog = WatchIdleAsync(dialer, rpc, log, linked.Token);
|
||||
log.Info("dialer connected", ("phase", "control_session"), ("version", info.Version ?? ""), ("remote", info.Remote));
|
||||
await _uplink.EmitEventAsync(AgentEventKinds.DialerConnected, info.PeerId, Describe(dialer));
|
||||
await PushAsync(dialer, initial, linked.Token);
|
||||
try
|
||||
{
|
||||
await run;
|
||||
@@ -170,25 +174,35 @@ public sealed class PeerHub
|
||||
{
|
||||
// 停止
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
dialer.CloseReason ??= "control_error";
|
||||
log.Warn("dialer control session failed", ("exception", ex.GetType().Name), ("err", ex.Message));
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessions.Remove(probe);
|
||||
_sessions.Remove(dialer);
|
||||
await linked.CancelAsync();
|
||||
await watchdog;
|
||||
await rpc.DisposeAsync();
|
||||
await _tasks.CloseSessionAsync(probe);
|
||||
_log.Info("probe disconnected", ("probe", info.PeerId));
|
||||
OnProbeLost?.Invoke(probe);
|
||||
await _uplink.EmitEventAsync(AgentEventKinds.ProbeDisconnected, info.PeerId, Describe(probe));
|
||||
await _tasks.CloseSessionAsync(dialer);
|
||||
log.Info("dialer disconnected", ("phase", "control_session"),
|
||||
("reason", dialer.CloseReason ?? (ct.IsCancellationRequested ? "service_stopping" : "peer_closed")),
|
||||
("duration_ms", _time.GetUtcNow().ToUnixTimeMilliseconds() - dialer.ConnectedAtUtcMs),
|
||||
("datagram_bound", dialer.DatagramConnection is not null), ("data_remote", dialer.DatagramRemoteEndPoint));
|
||||
OnDialerLost?.Invoke(dialer);
|
||||
await _uplink.EmitEventAsync(AgentEventKinds.DialerDisconnected, info.PeerId, Describe(dialer));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 <see cref="IdleLimit"/> 没有收到任何东西,
|
||||
/// 说明 Probe 已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 probe_disconnected,而不是一直显示在线。
|
||||
/// 拨号方 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 <see cref="IdleLimit"/> 没有收到任何东西,
|
||||
/// 说明拨号方已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 dialer_disconnected,而不是一直显示在线。
|
||||
/// </summary>
|
||||
private async Task WatchIdleAsync(ProbeSession probe, RpcSession<PeerContext> rpc, CancellationToken ct)
|
||||
private async Task WatchIdleAsync(DialerSession dialer, RpcSession<PeerContext> rpc, INodeLog log, CancellationToken ct)
|
||||
{
|
||||
long lastSummary = _time.GetTimestamp();
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), _time);
|
||||
try
|
||||
{
|
||||
@@ -196,10 +210,18 @@ public sealed class PeerHub
|
||||
{
|
||||
if (rpc.IdleFor > IdleLimit)
|
||||
{
|
||||
_log.Warn("probe session is silent, closing it", ("probe", probe.ProbeId), ("idle_s", (long)rpc.IdleFor.TotalSeconds));
|
||||
dialer.CloseReason = "idle_timeout";
|
||||
log.Warn("dialer session is silent, closing it", ("phase", "control_session"), ("idle_s", (long)rpc.IdleFor.TotalSeconds), ("limit_s", IdleLimit.TotalSeconds));
|
||||
await rpc.CloseAsync("idle", CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
if (_time.GetElapsedTime(lastSummary) >= TimeSpan.FromSeconds(60))
|
||||
{
|
||||
lastSummary = _time.GetTimestamp();
|
||||
log.Info("dialer session summary", ("phase", "control_session"), ("remote", dialer.Info.Remote),
|
||||
("control_idle_s", (long)rpc.IdleFor.TotalSeconds), ("datagram_bound", dialer.DatagramConnection is not null),
|
||||
("data_remote", dialer.DatagramRemoteEndPoint));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -208,24 +230,23 @@ public sealed class PeerHub
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
|
||||
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
|
||||
/// <summary>当前连着的拨号方(心跳和 Backend 重连之后的补报都用它)。</summary>
|
||||
public IReadOnlyList<ConnectedDialer> ConnectedDialers() => [.. _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, IpVersion = p.IpVersion };
|
||||
private static ConnectedDialer Describe(DialerSession p) => new() { Id = new NodeId(p.DialerId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
|
||||
|
||||
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
|
||||
/// <summary>向一个拨号方下发它的线路任务集(拨号方 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
|
||||
public async ValueTask PushToAsync(DialerSession dialer, LineTaskSet set) => await PushAsync(dialer, set, CancellationToken.None);
|
||||
|
||||
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
|
||||
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
|
||||
|
||||
/// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
|
||||
public async ValueTask<bool> DisconnectAsync(string probeId)
|
||||
/// <summary>Backend 要求断开某个拨号方(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
|
||||
public async ValueTask<bool> DisconnectAsync(string dialerId)
|
||||
{
|
||||
bool disconnected = false;
|
||||
foreach (ProbeSession probe in _sessions.All)
|
||||
foreach (DialerSession dialer in _sessions.All)
|
||||
{
|
||||
if (probe.ProbeId == probeId && probe.Rpc is { } rpc)
|
||||
if (dialer.DialerId == dialerId && dialer.Rpc is { } rpc)
|
||||
{
|
||||
dialer.CloseReason = "disconnected";
|
||||
await rpc.CloseAsync("disconnected", CancellationToken.None);
|
||||
disconnected = true;
|
||||
}
|
||||
@@ -234,22 +255,23 @@ public sealed class PeerHub
|
||||
return disconnected;
|
||||
}
|
||||
|
||||
/// <summary>名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。</summary>
|
||||
/// <summary>名单(期望状态里的 peers)里已经没有的拨号方:立刻关闭它的会话(吊销生效)。</summary>
|
||||
public void CloseNotIn(IReadOnlySet<string> allowed)
|
||||
{
|
||||
foreach (ProbeSession probe in _sessions.All)
|
||||
foreach (DialerSession dialer in _sessions.All)
|
||||
{
|
||||
if (!allowed.Contains(probe.ProbeId) && probe.Rpc is { } rpc)
|
||||
if (!allowed.Contains(dialer.DialerId) && dialer.Rpc is { } rpc)
|
||||
{
|
||||
_log.Info("probe is no longer allowed, closing its session", ("probe", probe.ProbeId));
|
||||
dialer.CloseReason = "revoked";
|
||||
_log.Info("dialer is no longer allowed, closing its session", ("dialer", dialer.DialerId));
|
||||
_ = rpc.CloseAsync("revoked", CancellationToken.None).AsTask();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask PushAsync(ProbeSession probe, LineTaskSet set, CancellationToken ct)
|
||||
private async ValueTask PushAsync(DialerSession dialer, LineTaskSet set, CancellationToken ct)
|
||||
{
|
||||
if (probe.Rpc is null)
|
||||
if (dialer.Rpc is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -262,10 +284,10 @@ public sealed class PeerHub
|
||||
};
|
||||
try
|
||||
{
|
||||
Result<StateSyncResult> result = await probe.Rpc.RequestAsync(FleetMethods.StateSync, parameters, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, ct);
|
||||
Result<StateSyncResult> result = await dialer.Rpc.RequestAsync(FleetMethods.StateSync, parameters, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_log.Warn("probe rejected the line task set", ("err", result.Error.Code));
|
||||
_log.Warn("dialer rejected the line task set", ("err", result.Error.Code));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException or OperationCanceledException)
|
||||
@@ -280,5 +302,5 @@ public sealed class PeerHub
|
||||
MoShiroMethods.Clock,
|
||||
MoShiroJsonContext.Default.ClockParams,
|
||||
MoShiroJsonContext.Default.ClockResult,
|
||||
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() })));
|
||||
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { ListenerMonoUs = context.Clock.NowUs() })));
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using Fleet.Hosting;
|
||||
using MoShiro.Agent;
|
||||
|
||||
TimeProvider time = TimeProvider.System;
|
||||
INodeLog log = new ConsoleNodeLog(time);
|
||||
INodeLog log = new BufferedNodeLog(new ConsoleNodeLog(time), time);
|
||||
|
||||
if (PlatformCheck.VerifyCrypto() is { } problem)
|
||||
{
|
||||
@@ -43,7 +43,7 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
|
||||
using var restartScope = restart;
|
||||
using (identity)
|
||||
{
|
||||
using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log, restart);
|
||||
await using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log, restart);
|
||||
await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 自适应探测频率的状态变化,等着随下一次心跳上报给 Backend(Backend 存成事件,用于事后审计)。
|
||||
/// 和普通日志分开存:日志环形缓冲几分钟到几小时就会被覆盖,而这里的变化要能在几天后回看。有界,满了丢最旧的。
|
||||
/// </summary>
|
||||
public sealed class RateChangeLog
|
||||
{
|
||||
public const int Capacity = 512;
|
||||
|
||||
private readonly ConcurrentQueue<RateChange> _queue = new();
|
||||
|
||||
public void Add(RateChange change)
|
||||
{
|
||||
_queue.Enqueue(change);
|
||||
while (_queue.Count > Capacity && _queue.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>取走最多 <paramref name="max"/> 条(最旧的在前);没有返回 null,心跳里就不带这个字段。</summary>
|
||||
public List<RateChange>? Take(int max)
|
||||
{
|
||||
List<RateChange>? taken = null;
|
||||
while ((taken?.Count ?? 0) < max)
|
||||
{
|
||||
if (!_queue.TryDequeue(out RateChange? change))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
(taken ??= []).Add(change);
|
||||
}
|
||||
|
||||
return taken;
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,16 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect),
|
||||
/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标地址必须在本地 <c>allowed_target_cidrs</c> 内
|
||||
/// (由 <see cref="BudgetGuard"/> 在接受任务之前检查)。
|
||||
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_ping、connect),
|
||||
/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标可以是 IP、域名或(http)URL;域名在 Agent 上解析,
|
||||
/// 每个解析出的地址都必须在本地 <c>allowed_target_cidrs</c> 内(IP 目标由 <see cref="BudgetGuard"/> 在接受任务之前检查,域名在这里检查)。
|
||||
/// </summary>
|
||||
public sealed class TargetRunners
|
||||
{
|
||||
private static readonly TimeSpan s_chunkSpan = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
|
||||
|
||||
private static readonly TimeSpan s_resolveRetry = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly Dictionary<string, Running> _running = [];
|
||||
private readonly object _gate = new();
|
||||
private readonly ChunkAssembler _chunks;
|
||||
@@ -22,9 +24,11 @@ public sealed class TargetRunners
|
||||
private readonly MonoClock _clock;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly INodeLog _log;
|
||||
private readonly RateChangeLog? _rateChanges;
|
||||
|
||||
public TargetRunners(ChunkAssembler chunks, NodeId agent, MonoClock clock, TimeProvider time, INodeLog log)
|
||||
public TargetRunners(ChunkAssembler chunks, NodeId agent, MonoClock clock, TimeProvider time, INodeLog log, RateChangeLog? rateChanges = null)
|
||||
{
|
||||
_rateChanges = rateChanges;
|
||||
_chunks = chunks;
|
||||
_agent = agent;
|
||||
_clock = clock;
|
||||
@@ -33,7 +37,7 @@ public sealed class TargetRunners
|
||||
}
|
||||
|
||||
/// <summary>让运行中的任务与期望集合一致:参数没有变化的保持,其余启动或停止。</summary>
|
||||
public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired)
|
||||
public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired, Func<IPAddress, bool>? allow = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
@@ -56,7 +60,7 @@ public sealed class TargetRunners
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_running[id] = new Running(Signature(spec), cts);
|
||||
_ = Task.Run(() => RunAsync(spec, decision, cts.Token), CancellationToken.None);
|
||||
_ = Task.Run(() => RunAsync(spec, decision, allow, cts.Token), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,23 +68,34 @@ public sealed class TargetRunners
|
||||
private static string Signature(TaskSpec spec) =>
|
||||
spec.Kind + "|" + spec.Target + "|" + string.Join(',', spec.Params.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
|
||||
|
||||
private async Task RunAsync(TaskSpec spec, TaskDecision decision, CancellationToken ct)
|
||||
private async Task RunAsync(TaskSpec spec, TaskDecision decision, Func<IPAddress, bool>? allow, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TryResolve(spec.Target!, decision.Resolved!, spec.Kind, out IPAddress? address, out int port))
|
||||
if (!TargetHost.TryParse(spec.Target, out string host, out int targetPort, out _))
|
||||
{
|
||||
_log.Warn("target is not an address", ("task", spec.Id.Value));
|
||||
_log.Warn("target is not valid", ("task", spec.Id.Value));
|
||||
return;
|
||||
}
|
||||
|
||||
// http 自己解析并检查每个地址;其余 Kind 在这里解析出一个允许的地址作为对端
|
||||
IPAddress? address = null;
|
||||
if (spec.Kind != TargetHost.HttpKind)
|
||||
{
|
||||
address = await ResolveAllowedAsync(spec, host, allow, ct);
|
||||
}
|
||||
|
||||
int port = targetPort != 0 ? targetPort : spec.Kind == "tcp_connect" ? (int)decision.Resolved!.GetInt("port") : 0;
|
||||
var env = new BlockEnv
|
||||
{
|
||||
Clock = _clock,
|
||||
Log = _log,
|
||||
Session = () => null,
|
||||
PeerAddress = address,
|
||||
Fragments = new LocalSink(this, spec, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
|
||||
Target = spec.Target,
|
||||
AllowAddress = allow,
|
||||
OnRateChange = _rateChanges is null ? null : _rateChanges.Add,
|
||||
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);
|
||||
@@ -110,24 +125,32 @@ public sealed class TargetRunners
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolve(string target, ParamValues values, string kind, out IPAddress address, out int port)
|
||||
/// <summary>解析目标,取第一个允许的地址;解析失败或没有允许的地址就每分钟重试(域名的解析结果会变)。</summary>
|
||||
private async Task<IPAddress> ResolveAllowedAsync(TaskSpec spec, string host, Func<IPAddress, bool>? allow, CancellationToken ct)
|
||||
{
|
||||
port = kind == "connect" ? (int)values.GetInt("port") : 0;
|
||||
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
|
||||
while (true)
|
||||
{
|
||||
address = endpoint.Address;
|
||||
if (endpoint.Port != 0)
|
||||
try
|
||||
{
|
||||
port = endpoint.Port;
|
||||
IPAddress[] addresses = IPAddress.TryParse(host, out IPAddress? literal) ? [literal] : await Dns.GetHostAddressesAsync(host, ct);
|
||||
IPAddress? chosen = addresses.FirstOrDefault(a => allow?.Invoke(a) ?? true);
|
||||
if (chosen is not null)
|
||||
{
|
||||
return chosen;
|
||||
}
|
||||
|
||||
_log.Warn("no address of the target is allowed", ("task", spec.Id.Value), ("host", host));
|
||||
}
|
||||
catch (System.Net.Sockets.SocketException ex)
|
||||
{
|
||||
_log.Warn("target did not resolve", ("task", spec.Id.Value), ("host", host), ("err", ex.Message));
|
||||
}
|
||||
|
||||
return true;
|
||||
await Task.Delay(s_resolveRetry, _time, ct);
|
||||
}
|
||||
|
||||
return IPAddress.TryParse(target, out address!);
|
||||
}
|
||||
|
||||
/// <summary>目标任务的行没有 Probe 一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
|
||||
/// <summary>目标任务的行没有拨号方一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
|
||||
private sealed class LocalSink : IFragmentSink
|
||||
{
|
||||
private readonly TargetRunners _owner;
|
||||
@@ -156,7 +179,7 @@ public sealed class TargetRunners
|
||||
Port = port,
|
||||
StartUtcMs = startUtcMs,
|
||||
DurationMs = Math.Clamp(durationMs, 1, ChunkFinalizer.MaxDurationMs),
|
||||
Clock = new ChunkClock { AgentMonoUs = anchor.AgentMonoUs, UtcMs = anchor.UtcMs, ProbeOffsetUs = 0 },
|
||||
Clock = new ChunkClock { ListenerMonoUs = anchor.ListenerMonoUs, UtcMs = anchor.UtcMs, DialerOffsetUs = 0 },
|
||||
};
|
||||
_owner._chunks.Emit(new RawChunk(header, streams));
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。
|
||||
/// 通过的线路任务<b>为每个 Probe 会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个 Probe;
|
||||
/// 通过的目标任务在本地运行,与 Probe 无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
|
||||
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个 Probe 会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
|
||||
/// 通过的线路任务<b>为每个拨号方会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个拨号方;
|
||||
/// 通过的目标任务在本地运行,与拨号方无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
|
||||
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个拨号方会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
|
||||
/// </summary>
|
||||
public sealed class TaskManager
|
||||
{
|
||||
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(udp_train、tcp_bulk 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
|
||||
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(quic_stream、tcp_transfer 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
|
||||
private static readonly HashSet<string> s_lineKinds = [.. BlockCatalog.All.Where(p => p.Listener is not null && !p.OnDemandOnly).Select(p => p.Kind)];
|
||||
private static readonly HashSet<string> s_targetKinds = [.. BlockCatalog.All.Where(p => p.RunsAsTarget).Select(p => p.Kind)];
|
||||
|
||||
@@ -24,65 +24,67 @@ public sealed class TaskManager
|
||||
private readonly StreamRegistry _streams;
|
||||
private readonly BlockEnv _env;
|
||||
private readonly TargetRunners _targets;
|
||||
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
||||
private readonly Func<IReadOnlyList<int>> _quicPorts;
|
||||
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
||||
private readonly TrustAnchor _trust;
|
||||
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||||
private readonly bool _acceptLineTasks;
|
||||
private readonly Dictionary<DialerSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||||
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
|
||||
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
|
||||
private long _version;
|
||||
private BudgetGuard? _guard;
|
||||
|
||||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
|
||||
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null, bool acceptLineTasks = true)
|
||||
{
|
||||
_trust = trust ?? TrustAnchor.None;
|
||||
_acceptLineTasks = acceptLineTasks;
|
||||
_guardFactory = guardFactory;
|
||||
_streams = streams;
|
||||
_env = env;
|
||||
_targets = targets;
|
||||
_udpPorts = udpPorts;
|
||||
_quicPorts = quicPorts;
|
||||
_tcpPorts = tcpPorts;
|
||||
}
|
||||
|
||||
public BudgetGuard Guard => _guard ??= _guardFactory();
|
||||
|
||||
/// <summary>某个 Probe 的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个 Probe。</summary>
|
||||
public Action<ProbeSession, LineTaskSet>? OnLineTasksChanged { get; set; }
|
||||
/// <summary>某个拨号方的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个拨号方。</summary>
|
||||
public Action<DialerSession, LineTaskSet>? OnLineTasksChanged { get; set; }
|
||||
|
||||
/// <summary>允许连接的 Probe 变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
|
||||
/// <summary>允许连接的拨号方变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
|
||||
public Action<IReadOnlySet<string>>? OnPeersChanged { get; set; }
|
||||
|
||||
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
|
||||
/// <summary>点对点握手时查找拨号方的公钥(SPKI)。</summary>
|
||||
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
|
||||
|
||||
/// <summary>名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。</summary>
|
||||
/// <summary>名册里全部拨号方的公钥:敲门关卡用它们派生配对密钥。</summary>
|
||||
public IReadOnlyList<byte[]> PeerKeys() => [.. _peers.Values];
|
||||
|
||||
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
|
||||
public LineTaskSet OpenSession(ProbeSession probe)
|
||||
/// <summary>一个新的拨号方会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
|
||||
public LineTaskSet OpenSession(DialerSession dialer)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var entries = new Dictionary<string, LineEntry>();
|
||||
foreach ((string id, (TaskSpec task, TaskDecision decision)) in _lineSpecs)
|
||||
{
|
||||
LineEntry entry = BuildLineEntry(task, decision, probe);
|
||||
LineEntry entry = BuildLineEntry(task, decision, dialer);
|
||||
Start(entry);
|
||||
entries[id] = entry;
|
||||
}
|
||||
|
||||
_sessionLines[probe] = entries;
|
||||
_sessionLines[dialer] = entries;
|
||||
return SetFor(entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Probe 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
|
||||
public async Task CloseSessionAsync(ProbeSession probe)
|
||||
/// <summary>拨号方 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
|
||||
public async Task CloseSessionAsync(DialerSession dialer)
|
||||
{
|
||||
List<LineEntry> entries;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_sessionLines.Remove(probe, out Dictionary<string, LineEntry>? lines))
|
||||
if (!_sessionLines.Remove(dialer, out Dictionary<string, LineEntry>? lines))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -108,11 +110,11 @@ public sealed class TaskManager
|
||||
{
|
||||
try
|
||||
{
|
||||
peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey);
|
||||
peers[peer.NodeId.Value] = Convert.FromBase64String(peer.PublicKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.InvalidParams });
|
||||
result.Rejected.Add(new RejectedItem { Id = peer.NodeId.Value, Reason = RejectReasons.InvalidParams });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +133,7 @@ public sealed class TaskManager
|
||||
continue;
|
||||
}
|
||||
|
||||
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : s_lineKinds.Contains(task.Kind);
|
||||
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : _acceptLineTasks && s_lineKinds.Contains(task.Kind);
|
||||
TaskDecision decision = shapeOk ? guard.Check(task, ref usedMbps, ref usedPps) : TaskDecision.Reject(RejectReasons.Unsupported);
|
||||
if (!decision.Accepted)
|
||||
{
|
||||
@@ -149,27 +151,29 @@ public sealed class TaskManager
|
||||
}
|
||||
}
|
||||
|
||||
_targets.Reconcile(targets);
|
||||
_targets.Reconcile(targets, guard.AddressAllowed);
|
||||
|
||||
// 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断
|
||||
var retired = new List<LineEntry>();
|
||||
var changes = new List<(ProbeSession Probe, LineTaskSet Set)>();
|
||||
var changes = new List<(DialerSession Dialer, LineTaskSet Set)>();
|
||||
lock (_gate)
|
||||
{
|
||||
_lineSpecs = line;
|
||||
_version = version;
|
||||
foreach ((ProbeSession probe, Dictionary<string, LineEntry> current) in _sessionLines)
|
||||
foreach ((DialerSession dialer, Dictionary<string, LineEntry> current) in _sessionLines)
|
||||
{
|
||||
var next = new Dictionary<string, LineEntry>();
|
||||
bool lineTasksChanged = current.Count != line.Count;
|
||||
foreach ((string id, (TaskSpec task, TaskDecision decision)) in line)
|
||||
{
|
||||
LineEntry candidate = BuildLineEntry(task, decision, probe);
|
||||
LineEntry candidate = BuildLineEntry(task, decision, dialer);
|
||||
if (current.TryGetValue(id, out LineEntry? existing) && existing.Signature == candidate.Signature)
|
||||
{
|
||||
next[id] = existing;
|
||||
}
|
||||
else
|
||||
{
|
||||
lineTasksChanged = true;
|
||||
Start(candidate);
|
||||
next[id] = candidate;
|
||||
}
|
||||
@@ -189,7 +193,13 @@ public sealed class TaskManager
|
||||
current[id] = entry;
|
||||
}
|
||||
|
||||
changes.Add((probe, SetFor(current)));
|
||||
// The Agent receives a full task snapshot for peer/trust changes and target-only tasks too.
|
||||
// Those changes do not affect a 拨号方's line blocks, so avoid retransmitting the same full
|
||||
// line_task_set to every 拨号方. New sessions still get their initial snapshot in OpenSession.
|
||||
if (lineTasksChanged)
|
||||
{
|
||||
changes.Add((dialer, SetFor(current)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,15 +209,15 @@ public sealed class TaskManager
|
||||
}
|
||||
|
||||
OnPeersChanged?.Invoke(new HashSet<string>(peers.Keys, StringComparer.Ordinal));
|
||||
foreach ((ProbeSession probe, LineTaskSet next) in changes)
|
||||
foreach ((DialerSession dialer, LineTaskSet next) in changes)
|
||||
{
|
||||
OnLineTasksChanged?.Invoke(probe, next);
|
||||
OnLineTasksChanged?.Invoke(dialer, next);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>停止时释放所有 Probe 会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
|
||||
/// <summary>停止时释放所有拨号方会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
_targets.Reconcile([]);
|
||||
@@ -229,13 +239,13 @@ public sealed class TaskManager
|
||||
{
|
||||
Version = _version,
|
||||
Tasks = [.. entries.Values.Select(e => e.Task)],
|
||||
UdpPorts = [.. _udpPorts()],
|
||||
QuicPorts = [.. _quicPorts()],
|
||||
TcpPorts = [.. _tcpPorts()],
|
||||
};
|
||||
|
||||
private LineEntry BuildLineEntry(TaskSpec task, TaskDecision decision, ProbeSession probe)
|
||||
private LineEntry BuildLineEntry(TaskSpec task, TaskDecision decision, DialerSession dialer)
|
||||
{
|
||||
var info = new StreamInfo(_streams.Allocate(), task.Kind, decision.Resolved!) { TaskId = task.Id, Owner = probe };
|
||||
var info = new StreamInfo(_streams.Allocate(), task.Kind, decision.Resolved!) { TaskId = task.Id, Owner = dialer };
|
||||
var lineTask = new LineTask
|
||||
{
|
||||
Id = task.Id,
|
||||
@@ -245,7 +255,7 @@ public sealed class TaskManager
|
||||
Port = info.Port,
|
||||
};
|
||||
var spec = new BlockSpec(decision.Kind!, decision.Resolved, info.Id, info.Port, new FragmentScope(task.Id, null, null));
|
||||
return new LineEntry(lineTask, info, spec, _env.WithSession(probe));
|
||||
return new LineEntry(lineTask, info, spec, _env.WithSession(dialer));
|
||||
}
|
||||
|
||||
/// <summary>在数据面登记这个流并启动它的监听方积木。</summary>
|
||||
|
||||
@@ -8,9 +8,9 @@ namespace MoShiro.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 按需测试的编排(<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 掉线或被取消时测试中止,已经上传的阶段数据保留。
|
||||
/// 登记数据面的流 → <c>moshiro.test.phase</c>(拨号方 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待拨号方发包)
|
||||
/// → <c>moshiro.test.phase_end</c>(拨号方 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
|
||||
/// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;拨号方 掉线或被取消时测试中止,已经上传的阶段数据保留。
|
||||
/// </summary>
|
||||
public sealed class TestRunner
|
||||
{
|
||||
@@ -51,9 +51,9 @@ public sealed class TestRunner
|
||||
return Result<TestRunResult>.Fail(MoShiroErrors.ValidationFailed, "ip_version must be ipv4 or ipv6");
|
||||
}
|
||||
|
||||
if (_sessions.Get(run.ProbeId.Value, run.IpVersion) is not { } probe)
|
||||
if (_sessions.Get(run.DialerId.Value, run.IpVersion) is not { } dialer)
|
||||
{
|
||||
return Result<TestRunResult>.Fail(MoShiroErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent");
|
||||
return Result<TestRunResult>.Fail(MoShiroErrors.DialerOffline, $"dialer {run.DialerId.Value} is not connected to this agent");
|
||||
}
|
||||
|
||||
if (run.Phases.Count == 0)
|
||||
@@ -85,7 +85,7 @@ public sealed class TestRunner
|
||||
resolved.Add((phase, _tasks.Guard.CheckPhase(phase.Kind, phase.Params)));
|
||||
}
|
||||
|
||||
var running = new RunningTest(probe);
|
||||
var running = new RunningTest(dialer);
|
||||
if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running))
|
||||
{
|
||||
running.Dispose();
|
||||
@@ -107,7 +107,7 @@ public sealed class TestRunner
|
||||
|
||||
// 按需测试的数据端口由 Agent 决定:Backend 给的端口如果不是本机监听的端口(模板里是出厂默认值,每个 Agent 可以改,
|
||||
// 而 Backend 并不知道 TCP 数据端口),就用本机监听的第一个端口。块头里记录的是实际用的端口。
|
||||
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "udp" ? "udp" : "tcp");
|
||||
IReadOnlyList<int> ports = _ports(kind.Blocks.Transport == "quic_datagram" ? "quic_datagram" : "tcp");
|
||||
bool listening = phase.Params.TryGetValue("port", out System.Text.Json.JsonElement requested) && requested.TryGetInt32(out int port) && ports.Contains(port);
|
||||
if (!listening)
|
||||
{
|
||||
@@ -126,12 +126,12 @@ public sealed class TestRunner
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>某个 Probe 掉线:它上面的测试中止,状态是 failed / probe_offline;别的 Probe 上的测试不受影响。</summary>
|
||||
public void ProbeLost(ProbeSession probe)
|
||||
/// <summary>某个拨号方掉线:它上面的测试中止,状态是 failed / dialer_offline;别的拨号方上的测试不受影响。</summary>
|
||||
public void DialerLost(DialerSession dialer)
|
||||
{
|
||||
foreach (RunningTest running in _running.Values)
|
||||
{
|
||||
if (ReferenceEquals(running.Probe, probe))
|
||||
if (ReferenceEquals(running.Dialer, dialer))
|
||||
{
|
||||
running.Lost = true;
|
||||
running.Cancel.Cancel();
|
||||
@@ -147,26 +147,26 @@ public sealed class TestRunner
|
||||
int? lastPhase = null;
|
||||
try
|
||||
{
|
||||
ProbeSession probe = running.Probe;
|
||||
DialerSession dialer = running.Dialer;
|
||||
for (int index = 0; index < phases.Count; index++)
|
||||
{
|
||||
lastPhase = index;
|
||||
await _uplink.SendTestStatusAsync(test, TestStatuses.Running, index);
|
||||
await RunPhaseAsync(probe, test, index, phases[index].Phase, phases[index].Decision, ct);
|
||||
await RunPhaseAsync(dialer, test, index, phases[index].Phase, phases[index].Decision, ct);
|
||||
await _uplink.SendTestStatusAsync(test, TestStatuses.PhaseDone, index);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
finalStatus = running.Lost ? TestStatuses.Failed : TestStatuses.Aborted;
|
||||
errorCode = running.Lost ? MoShiroErrors.ProbeOffline.Value : null;
|
||||
await TryAbortProbeAsync(running.Probe, test);
|
||||
errorCode = running.Lost ? MoShiroErrors.DialerOffline.Value : null;
|
||||
await TryAbortDialerAsync(running.Dialer, test);
|
||||
}
|
||||
catch (TestFailure failure)
|
||||
{
|
||||
finalStatus = TestStatuses.Failed;
|
||||
errorCode = failure.Code;
|
||||
await TryAbortProbeAsync(running.Probe, test);
|
||||
await TryAbortDialerAsync(running.Dialer, test);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -178,17 +178,17 @@ public sealed class TestRunner
|
||||
await _uplink.SendTestStatusAsync(test, finalStatus, lastPhase, errorCode);
|
||||
}
|
||||
|
||||
private async Task RunPhaseAsync(ProbeSession probe, TestId test, int index, TestPhase phase, TaskDecision decision, CancellationToken ct)
|
||||
private async Task RunPhaseAsync(DialerSession dialer, TestId test, int index, TestPhase phase, TaskDecision decision, CancellationToken ct)
|
||||
{
|
||||
ParamValues values = decision.Resolved!;
|
||||
var stream = new StreamInfo(_streams.Allocate(), phase.Kind, values) { TestId = test, Phase = index, Owner = probe };
|
||||
var stream = new StreamInfo(_streams.Allocate(), phase.Kind, values) { TestId = test, Phase = index, Owner = dialer };
|
||||
int durationSeconds = phase.DurationSeconds > 0 ? phase.DurationSeconds : (int)(values.Values.ContainsKey("duration_s") ? values.GetInt("duration_s") : 10);
|
||||
var blockSpec = new BlockSpec(decision.Kind!, values, stream.Id, stream.Port, new FragmentScope(null, test, index));
|
||||
stream.Running = RunningBlock.Start(BlockCatalog.Find(phase.Kind)!.Listener!(_env.WithSession(probe), blockSpec), blockSpec.Signature);
|
||||
stream.Running = RunningBlock.Start(BlockCatalog.Find(phase.Kind)!.Listener!(_env.WithSession(dialer), blockSpec), blockSpec.Signature);
|
||||
_streams.Add(stream);
|
||||
try
|
||||
{
|
||||
RpcSession<PeerContext> rpc = probe.Rpc ?? throw new TestFailure(MoShiroErrors.ProbeOffline.Value);
|
||||
RpcSession<PeerContext> rpc = dialer.Rpc ?? throw new TestFailure(MoShiroErrors.DialerOffline.Value);
|
||||
var start = new TestPhaseStartParams
|
||||
{
|
||||
TestId = test,
|
||||
@@ -206,7 +206,7 @@ public sealed class TestRunner
|
||||
}
|
||||
|
||||
// 单向数据流的发送端自己运行到结束(或失败);其他积木是反应式的,等够时长即可
|
||||
if (phase.Kind == "udp_train")
|
||||
if (phase.Kind == "quic_stream")
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -214,7 +214,7 @@ public sealed class TestRunner
|
||||
}
|
||||
catch (BlockFaultException)
|
||||
{
|
||||
throw new TestFailure(MoShiroErrors.ProbeOffline.Value);
|
||||
throw new TestFailure(MoShiroErrors.DialerOffline.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -230,7 +230,7 @@ public sealed class TestRunner
|
||||
throw new TestFailure(endError.Code.Value);
|
||||
}
|
||||
|
||||
// 列块是经 Probe 的发件箱至少一次送达的:全部合并上传之后才进入下一阶段(超时就带着已有的数据继续)
|
||||
// 列块是经拨号方的发件箱至少一次送达的:全部合并上传之后才进入下一阶段(超时就带着已有的数据继续)
|
||||
DateTimeOffset deadline = _time.GetUtcNow() + s_fragmentWait;
|
||||
while (stream.FragmentsDone < finished.Fragments && _time.GetUtcNow() < deadline)
|
||||
{
|
||||
@@ -246,13 +246,13 @@ public sealed class TestRunner
|
||||
{
|
||||
_streams.Remove(stream.Id);
|
||||
await stream.Running.StopAsync();
|
||||
probe.ForgetStream(stream.Id);
|
||||
dialer.ForgetStream(stream.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryAbortProbeAsync(ProbeSession probe, TestId test)
|
||||
private async Task TryAbortDialerAsync(DialerSession dialer, TestId test)
|
||||
{
|
||||
if (probe.Rpc is not { } rpc)
|
||||
if (dialer.Rpc is not { } rpc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -267,15 +267,15 @@ public sealed class TestRunner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>一个正在运行的测试:它在哪个 Probe 上、怎样取消、是不是因为 Probe 掉线而中止。</summary>
|
||||
/// <summary>一个正在运行的测试:它在哪个拨号方上、怎样取消、是不是因为拨号方掉线而中止。</summary>
|
||||
private sealed class RunningTest : IDisposable
|
||||
{
|
||||
public RunningTest(ProbeSession probe)
|
||||
public RunningTest(DialerSession dialer)
|
||||
{
|
||||
Probe = probe;
|
||||
Dialer = dialer;
|
||||
}
|
||||
|
||||
public ProbeSession Probe { get; }
|
||||
public DialerSession Dialer { get; }
|
||||
|
||||
public CancellationTokenSource Cancel { get; } = new();
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
namespace MoShiro.Agent.Tests;
|
||||
|
||||
public sealed class ControlBackoffTests
|
||||
{
|
||||
[Fact]
|
||||
public void Next_UsesRequestedEscalationThenKeepsDoublingToTenMinutes()
|
||||
{
|
||||
var backoff = new ControlBackoff();
|
||||
TimeSpan[] expected =
|
||||
[
|
||||
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4),
|
||||
TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(16),
|
||||
TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(32),
|
||||
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(4),
|
||||
TimeSpan.FromMinutes(8), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(10),
|
||||
];
|
||||
|
||||
Assert.Equal(expected, expected.Select(_ => backoff.Next()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_RestartsAtOneSecond()
|
||||
{
|
||||
var backoff = new ControlBackoff();
|
||||
_ = backoff.Next();
|
||||
_ = backoff.Next();
|
||||
|
||||
backoff.Reset();
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), backoff.Next());
|
||||
}
|
||||
}
|
||||
+28
-28
@@ -2,28 +2,28 @@ using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Probe.Tests;
|
||||
namespace MoShiro.Agent.Tests;
|
||||
|
||||
public sealed class ProbeTests
|
||||
public sealed class DialDirectoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void AgentDirectory_DualStack_PersistsBothPaths_AndRemovingIpv6KeepsIpv4()
|
||||
public void DialDirectory_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";
|
||||
var agents = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
|
||||
DialTargets list = List(1, "agt_dual");
|
||||
list.Targets[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);
|
||||
Assert.Equal(new[] { "ipv4", "ipv6" }, agents.Targets.Select(a => a.IpVersion));
|
||||
var reloaded = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
|
||||
Assert.Equal(2, reloaded.Targets.Count);
|
||||
Assert.Equal("2001:db8::1", reloaded.Targets[1].Host);
|
||||
list.Version = 2;
|
||||
list.Agents[0].Ipv6Host = null;
|
||||
list.Targets[0].Ipv6Host = null;
|
||||
reloaded.Apply(list);
|
||||
Assert.Equal("ipv4", Assert.Single(reloaded.Agents).IpVersion);
|
||||
Assert.Equal("ipv4", Assert.Single(reloaded.Targets).IpVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -38,43 +38,43 @@ public sealed class ProbeTests
|
||||
Assert.False(clock.HasEstimate);
|
||||
|
||||
// Offset 1000 with a 50 ms round trip, offset 5000 with a 2 ms round trip (symmetry assumption is far better here)
|
||||
clock.Add(sendUs: 0, agentUs: 25_000 + 1000, receiveUs: 50_000);
|
||||
clock.Add(sendUs: 100_000, agentUs: 101_000 + 5000, receiveUs: 102_000);
|
||||
clock.Add(sendUs: 0, listenerUs: 25_000 + 1000, receiveUs: 50_000);
|
||||
clock.Add(sendUs: 100_000, listenerUs: 101_000 + 5000, receiveUs: 102_000);
|
||||
Assert.True(clock.HasEstimate);
|
||||
Assert.Equal(5000, clock.OffsetUs);
|
||||
|
||||
// Slow samples do not displace the good one
|
||||
clock.Add(sendUs: 200_000, agentUs: 300_000, receiveUs: 400_000);
|
||||
clock.Add(sendUs: 200_000, listenerUs: 300_000, receiveUs: 400_000);
|
||||
Assert.Equal(5000, clock.OffsetUs);
|
||||
}
|
||||
|
||||
private static string NewDirectory()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), "probe-tests-" + Guid.NewGuid().ToString("N"));
|
||||
string path = Path.Combine(Path.GetTempPath(), "dial-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static ProbeAgentList List(long version, params string[] ids) => new()
|
||||
private static DialTargets List(long version, params string[] ids) => new()
|
||||
{
|
||||
Version = version,
|
||||
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, PublicKey = "c3BraQ==" })],
|
||||
Targets = [.. ids.Select(id => new DialTarget { NodeId = new NodeId(id), Host = id + ".example", TcpPort = 7443, PublicKey = "c3BraQ==" })],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AgentDirectory_StartsEmpty_IgnoresStaleLists_PersistsChanges_AndReloadsThem()
|
||||
public void DialDirectory_StartsEmpty_IgnoresStaleLists_PersistsChanges_AndReloadsThem()
|
||||
{
|
||||
string directory = NewDirectory();
|
||||
try
|
||||
{
|
||||
var directoryA = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
|
||||
var directoryA = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
|
||||
int changes = 0;
|
||||
directoryA.Changed += () => changes++;
|
||||
Assert.Empty(directoryA.Agents);
|
||||
Assert.Empty(directoryA.Targets);
|
||||
|
||||
directoryA.Apply(List(5, "agt_1", "agt_2"));
|
||||
Assert.Equal(1, changes);
|
||||
Assert.Equal(["agt_1", "agt_2"], directoryA.Agents.Select(a => a.AgentId));
|
||||
Assert.Equal(["agt_1", "agt_2"], directoryA.Targets.Select(a => a.NodeId));
|
||||
|
||||
directoryA.Apply(List(4, "agt_old")); // out-of-order older push
|
||||
directoryA.Apply(List(5, "agt_1", "agt_2")); // same version and content
|
||||
@@ -82,8 +82,8 @@ public sealed class ProbeTests
|
||||
Assert.Equal(5, directoryA.Version);
|
||||
|
||||
// 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));
|
||||
var directoryB = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
|
||||
Assert.Equal(["agt_1", "agt_2"], directoryB.Targets.Select(a => a.NodeId));
|
||||
Assert.Equal(5, directoryB.Version);
|
||||
}
|
||||
finally
|
||||
@@ -93,14 +93,14 @@ public sealed class ProbeTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentDirectory_StartsEmpty_WhenTheSavedFileIsCorrupt()
|
||||
public void DialDirectory_StartsEmpty_WhenTheSavedFileIsCorrupt()
|
||||
{
|
||||
string directory = NewDirectory();
|
||||
try
|
||||
{
|
||||
new ConfigStore(directory).Write(AgentDirectory.FileName, "{ not json"u8.ToArray());
|
||||
var loaded = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
|
||||
Assert.Empty(loaded.Agents);
|
||||
new ConfigStore(directory).Write(DialDirectory.FileName, "{ not json"u8.ToArray());
|
||||
var loaded = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
|
||||
Assert.Empty(loaded.Targets);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using Fleet.Core;
|
||||
using Fleet.Link;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent.Tests;
|
||||
|
||||
public sealed class LinkStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void QuicRedialBackoff_StaysShort_AndRestartsAfterReset()
|
||||
{
|
||||
var backoff = new QuicRedialBackoff();
|
||||
int[] seconds = [1, 2, 3, 5, 5, 5];
|
||||
Assert.Equal(seconds, seconds.Select(_ => (int)backoff.Next().TotalSeconds));
|
||||
|
||||
// 连续失败很多次(路径是死的):翻倍退避,最长五分钟
|
||||
int[] later = [.. Enumerable.Range(0, 12).Select(_ => (int)backoff.Next().TotalSeconds)];
|
||||
Assert.Equal([5, 5, 5, 10, 20, 40, 80, 160, 300], later[3..]);
|
||||
|
||||
backoff.Reset();
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), backoff.Next());
|
||||
TimeSpan jittered = new QuicRedialBackoff().Next(new Random(1));
|
||||
Assert.InRange(jittered.TotalSeconds, 0.8, 1.2);
|
||||
}
|
||||
|
||||
public static TheoryData<Exception, string> Faults => new()
|
||||
{
|
||||
{ new OperationCanceledException(), "timeout" },
|
||||
{ new SocketException((int)SocketError.ConnectionRefused), "refused" },
|
||||
{ new SocketException((int)SocketError.HostNotFound), "dns" },
|
||||
{ new IOException("reset", new SocketException((int)SocketError.ConnectionReset)), "reset" },
|
||||
{ new AuthenticationException("bad certificate"), "tls" },
|
||||
{ new InvalidOperationException("peer authentication failed: unauthorized"), "auth" },
|
||||
{ new ControlSilentException("no frames"), "silent" },
|
||||
{ new IOException("closed"), "closed" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Faults))]
|
||||
public void LinkFaults_ClassifyTheFailureAndKeepThePhase(Exception exception, string kind)
|
||||
{
|
||||
LinkFault fault = LinkFaults.From(exception, "tls");
|
||||
|
||||
Assert.Equal(kind, fault.Kind);
|
||||
Assert.Equal("tls", fault.Phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ControlStatus_LogsEveryTransition_AndReportsTheFaultWhileInBackoff()
|
||||
{
|
||||
var log = new CaptureLog();
|
||||
var status = new LinkStatus("agt_1", "ipv4", TimeProvider.System, log);
|
||||
|
||||
status.SetControl(ControlState.Dialing);
|
||||
status.SetControl(ControlState.Authenticating);
|
||||
status.SetControl(ControlState.Syncing);
|
||||
status.SetControl(ControlState.Up);
|
||||
status.SetControl(ControlState.Backoff, new LinkFault { Phase = "control_session", Kind = "silent" }, attempt: 1, retryIn: TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.Equal(["authenticating", "syncing", "up", "backoff"], log.Items.Select(x => (string)x["to"]!));
|
||||
LinkHealth health = status.Snapshot(new Outbox(1 << 10), 0, null);
|
||||
Assert.Equal(ControlState.Backoff, health.Control.State);
|
||||
Assert.Equal("silent", health.Control.LastError?.Kind);
|
||||
Assert.InRange(health.Control.RetryInMs ?? -1, 1, 1000);
|
||||
|
||||
status.SetControl(ControlState.Up);
|
||||
Assert.Null(status.Snapshot(new Outbox(1 << 10), 0, null).Control.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuicStatus_LogsOnlyTheEdgesOfAnOutage_NotEveryRedial()
|
||||
{
|
||||
var log = new CaptureLog();
|
||||
var status = new LinkStatus("agt_1", "ipv4", TimeProvider.System, log);
|
||||
var fault = new LinkFault { Phase = "quic_handshake", Kind = "timeout" };
|
||||
|
||||
status.SetQuic(QuicState.Connecting);
|
||||
status.SetQuic(QuicState.Ready);
|
||||
status.SetQuic(QuicState.Retrying, fault, 1, TimeSpan.FromSeconds(1));
|
||||
for (int attempt = 1; attempt <= 5; attempt++)
|
||||
{
|
||||
status.SetQuic(QuicState.Connecting, attempt: attempt);
|
||||
status.SetQuic(QuicState.Retrying, fault, attempt + 1, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
status.SetQuic(QuicState.Connecting, attempt: 6);
|
||||
status.SetQuic(QuicState.Ready);
|
||||
|
||||
Assert.Equal(["ready", "retrying", "ready"], log.Items.Select(x => (string)x["to"]!));
|
||||
Assert.NotNull(log.Items[^1]["down_ms"]);
|
||||
Assert.True(status.Ready is false);
|
||||
status.SetControl(ControlState.Up);
|
||||
Assert.True(status.Ready);
|
||||
}
|
||||
|
||||
private sealed class CaptureLog : INodeLog
|
||||
{
|
||||
private readonly ConcurrentQueue<Dictionary<string, object?>> _items = new();
|
||||
|
||||
public IReadOnlyList<Dictionary<string, object?>> Items => [.. _items];
|
||||
|
||||
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
|
||||
{
|
||||
if (message == "link state")
|
||||
{
|
||||
_items.Enqueue(fields.ToDictionary(x => x.Key, x => x.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<ProjectReference Include="..\..\src\MoShiro.Probe\MoShiro.Probe.csproj" />
|
||||
<ProjectReference Include="..\..\src\MoShiro.Agent\MoShiro.Agent.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent.Tests;
|
||||
|
||||
public sealed class OpenWrtUpdaterTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-native-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void Offer_AndApply_WriteFixedFlags_NotRemoteManifestOrCommands()
|
||||
{
|
||||
var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance);
|
||||
updater.Offer();
|
||||
updater.RequestApply();
|
||||
Assert.Equal("check", File.ReadAllText(Path.Combine(_directory, "check.request")));
|
||||
Assert.Equal("check", File.ReadAllText(Path.Combine(_directory, "apply.request")));
|
||||
Assert.Equal(2, Directory.GetFiles(_directory).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_WritesHealthAndReadsHostStatus()
|
||||
{
|
||||
var store = new ConfigStore(_directory);
|
||||
store.Write("status.json", "{\"state\":\"rolled_back\",\"at\":123}"u8.ToArray());
|
||||
var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance);
|
||||
Assert.Equal("rolled_back", updater.Status()?.State);
|
||||
using JsonDocument health = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(_directory, "health.json")));
|
||||
Assert.Equal(BuildInfo.Version, health.RootElement.GetProperty("version").GetString());
|
||||
Assert.Equal(Environment.ProcessId, health.RootElement.GetProperty("pid").GetInt32());
|
||||
Assert.True(health.RootElement.GetProperty("at").GetInt64() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_MissingOrCorruptHostStatus_DoesNotStopCollector()
|
||||
{
|
||||
var updater = new OpenWrtUpdater(_directory, Path.Combine(_directory, "status.json"), TimeProvider.System, NullNodeLog.Instance);
|
||||
Assert.Null(updater.Status());
|
||||
new ConfigStore(_directory).Write("status.json", "not json"u8.ToArray());
|
||||
Assert.Null(updater.Status());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory))
|
||||
{
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Fleet.Core;
|
||||
using Fleet.Link;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Agent.Tests;
|
||||
|
||||
public sealed class TaskLinkTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QuicConnectionLoss_IsRedialedFromANewPort_WhileTheControlLayerStaysUp()
|
||||
{
|
||||
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256);
|
||||
using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddYears(100));
|
||||
var session = new DataSession(1, new byte[32]);
|
||||
var clock = new MonoClock(TimeProvider.System);
|
||||
await using var server = new QuicDatagramTransport(new SingleSession(session), null, clock, new DataPlaneCounters(), NullNodeLog.Instance,
|
||||
IPAddress.Loopback, [0], certificate);
|
||||
server.Bind();
|
||||
await using var client = new QuicDatagramTransport(session, clock, new DataPlaneCounters(), NullNodeLog.Instance,
|
||||
new IPEndPoint(IPAddress.Loopback, server.BoundPorts[0]), key.ExportSubjectPublicKeyInfo());
|
||||
var status = new LinkStatus("agt_1", "ipv4", TimeProvider.System, NullNodeLog.Instance);
|
||||
status.SetControl(ControlState.Up);
|
||||
using var link = new TaskLink(client, status, TimeProvider.System);
|
||||
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
Task run = link.RunAsync(stop.Token);
|
||||
|
||||
await WaitUntilAsync(() => status.Quic == QuicState.Ready, stop.Token);
|
||||
int firstPort = client.BoundPorts[0];
|
||||
await client.CloseClientAsync();
|
||||
|
||||
bool controlStayedUp = true;
|
||||
await WaitUntilAsync(() =>
|
||||
{
|
||||
controlStayedUp &= status.Control == ControlState.Up;
|
||||
return status.Quic == QuicState.Ready && client.BoundPorts[0] != firstPort;
|
||||
}, stop.Token);
|
||||
|
||||
Assert.True(controlStayedUp);
|
||||
LinkHealth health = status.Snapshot(new Outbox(1 << 10), 0, null);
|
||||
Assert.Equal(2, health.Task.Quic.HandshakeSuccesses);
|
||||
Assert.Equal(ControlState.Up, health.Control.State);
|
||||
await stop.CancelAsync();
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, CancellationToken ct)
|
||||
{
|
||||
while (!condition())
|
||||
{
|
||||
await Task.Delay(20, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -3,7 +3,7 @@
|
||||
「很多节点连到一个面板」这类产品都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置。Fleet 把这些做成与业务无关的库,MoShiro 是它的第一个使用者;产品只写业务。Fleet 不引用任何 `MoShiro.*`,也不出现产品的名字、端口和协议前缀,由 `ArchitectureTests` 在构建时检查。
|
||||
|
||||
```text
|
||||
┌──────────────────── 产品层(MoShiro:Protocol · Measurement · Agent · Probe · Backend)────────────────────┐
|
||||
┌──────────────────── 产品层(MoShiro:Protocol · Measurement · Agent · Backend)────────────────────┐
|
||||
└────────────────────────────────────────────┬─────────────────────────────────────────────────────────────┘
|
||||
│ 只依赖 Fleet
|
||||
┌───────────────────────────── Fleet ────────┴─────────────────────────────────────────────────────────────┐
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
## 设计原则
|
||||
|
||||
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)。
|
||||
1. 与业务无关:产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。
|
||||
2. 安全默认值:Fleet 本身不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
|
||||
3. 权限分离:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
|
||||
4. NativeAOT:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
|
||||
5. 更新不在 Fleet 里:Fleet 不包含更新逻辑,更新由节点外部的服务管理器或更新器完成。Quadlet 装的节点和 Backend 由退出后 systemd 重新拉镜像、脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新见 [路线图](../../docs/roadmap.md)。
|
||||
|
||||
## 身份
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
|
||||
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
|
||||
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
|
||||
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security.md`](../../docs/security.md) 第 8 节。
|
||||
- 方法必须声明:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security/security.md`](../../docs/security/security.md) 第 6 节。
|
||||
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
|
||||
|
||||
| 保留方法 | 方向 | 说明 |
|
||||
@@ -64,6 +64,7 @@
|
||||
| `fleet.challenge` / `fleet.auth` | 服务端→节点 / 节点→服务端 | 签名认证(`auth` 用途,nonce 30 秒内有效) |
|
||||
| `fleet.hello` | 节点→服务端 | 版本、能力、本地策略摘要、当前时间 → 返回时钟偏差;产品的元数据也放在这里 |
|
||||
| `fleet.heartbeat` | 节点→服务端 | 每 10 秒一次,产品可以附加计数 |
|
||||
| `fleet.stopping` | 节点→服务端 | 有序关闭前通知服务端,携带 `reason`(例如 `stopping` 或 `updating`),用于准确区分离线和计划停机 |
|
||||
| `fleet.state.sync` | 服务端→节点 | `{version, doc}` → `{applied_version, rejected[]}` |
|
||||
| `fleet.outbox.ack` | 服务端→节点 | `{ids[], duplicates[]}` |
|
||||
| `fleet.revoked` | 服务端→节点 | 节点被禁用或接管,随后断开 |
|
||||
@@ -78,14 +79,14 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步
|
||||
|
||||
本地策略:`allow_methods`、`max_frame_bytes`、`max_inflight`。
|
||||
|
||||
**可靠发件箱**:产品 `EnqueueAsync` → 有界队列(按字节和条数;满时 `DropOldest` 或 `Reject`)→ 发送 → 服务端收件箱(`IInbox`)持久化后 `fleet.outbox.ack` → 移除。至少一次,按 `msg_id`(UUIDv7)去重;在途数据默认限 4 MiB;丢弃会累计缺口,通过 `OnGap` 通知产品。只在内存里,不落盘。
|
||||
可靠发件箱:产品 `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`),面板可以直接展示。
|
||||
期望状态同步:服务端 `IStateProvider<TDoc>` 给每个节点算 `{version, doc}`,变化时 `NotifyChanged` 推送;节点 `StateHandler<TDoc>` 调和后返回 `{applied_version, rejected[]}`。下发的是完整快照,天然幂等,重连后服务端总会重发当前版本。`rejected[]` 带结构化原因(`unsupported`、`budget_exceeded`、`policy_denied`),面板可以直接展示。
|
||||
|
||||
## 点对点会话
|
||||
|
||||
```text
|
||||
节点 A(发起方,如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent)
|
||||
节点 A(发起方,如 Agent A)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent B)
|
||||
<── fleet.peer.challenge ──
|
||||
── fleet.peer.auth(peer 用途,A 的私钥签名)──>
|
||||
B 用服务端下发的对端公钥列表验签(经 state.sync 下发),之后复用同一套 Rpc:路由、二进制通道、发件箱
|
||||
@@ -99,6 +100,6 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步
|
||||
|
||||
- `ModuleHost`:按依赖顺序启动模块、相反顺序停止,隔离故障,重启时退避。
|
||||
- `NodeConfig`:源生成 JSON 读取,环境变量覆盖,启动时校验。
|
||||
- `ConfigStore`:常驻进程里**唯一**允许写文件的地方,只能写配置目录,写入原子替换、权限 0600;父目录不存在会创建。
|
||||
- `ConfigStore`:常驻进程里唯一允许写文件的地方,只能写配置目录,写入原子替换、权限 0600;父目录不存在会创建。
|
||||
- `ServiceShell`:SIGTERM、Ctrl+C,systemd `sd_notify`、OpenWrt procd(前台运行)和 Windows 服务。
|
||||
- 服务端接口:`IFleetStore`(节点、令牌、公钥、状态版本)、`IStateProvider<TDoc>`、`IInbox`、`INodeEventSink`、`IEnrollmentHook`。MoShiro 把 `IFleetStore` 放进自己的 `moshiro.db`,和业务数据由同一个单写入者写入。
|
||||
@@ -1,43 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 节点握手时自述的「我跑的是哪个二进制」:正在运行的可执行文件的 SHA-256,以及(容器节点)宿主机更新器验过的 CI 签名清单和签名。
|
||||
/// 服务端在节点第一次握手成功的那一刻把哈希绑定到这个节点,之后哈希对不上就不让它继续运行。
|
||||
/// </summary>
|
||||
public sealed record BinaryAttestation(string Sha256, string? Manifest, string? Signature);
|
||||
|
||||
/// <summary>
|
||||
/// 正在运行的二进制的哈希。Linux 上读 <c>/proc/self/exe</c>:它指向进程启动时映射的那个映像,磁盘上的文件之后被换掉也不影响,
|
||||
/// 所以「运行期间被替换、重启后换了别的」的情况下一次握手就会对不上。算一次,之后缓存。
|
||||
/// </summary>
|
||||
public static class NodeBinary
|
||||
{
|
||||
private static readonly Lazy<string?> s_hash = new(Compute);
|
||||
|
||||
/// <summary>小写十六进制;读不到可执行文件时为 null(这样的节点不做绑定)。</summary>
|
||||
public static string? Sha256Hex => s_hash.Value;
|
||||
|
||||
/// <summary>在后台先算好,免得第一次握手等它。</summary>
|
||||
public static void Prime() => _ = Task.Run(() => s_hash.Value);
|
||||
|
||||
private static string? Compute()
|
||||
{
|
||||
try
|
||||
{
|
||||
string? path = OperatingSystem.IsLinux() && File.Exists("/proc/self/exe") ? "/proc/self/exe" : Environment.ProcessPath;
|
||||
if (path is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16);
|
||||
return Convert.ToHexStringLower(SHA256.HashData(stream));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 收尾类的等待(关闭、释放、等任务结束)一律要有上限:对端没了、原生层不回调、某个处理器卡住时,
|
||||
/// 没有上限的等待会让状态机永远停在「已连接」,既不重连也不报错。超时就放弃等待并交给调用方记日志;会话结束时的常见异常按正常噪声吞掉。
|
||||
/// </summary>
|
||||
public static class Bounded
|
||||
{
|
||||
/// <summary>收尾的一步最多等这么久。</summary>
|
||||
public static readonly TimeSpan Teardown = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>等 <paramref name="work"/> 最多 <paramref name="limit"/>;超时返回 false(任务继续在后台收尾,不再等它),否则返回 true。</summary>
|
||||
public static async Task<bool> WaitAsync(Task work, TimeSpan limit, TimeProvider time)
|
||||
{
|
||||
try
|
||||
{
|
||||
await work.WaitAsync(limit, time);
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or InvalidOperationException or SocketException or WebSocketException)
|
||||
{
|
||||
return true; // 会话结束时的正常噪声:这一步已经结束了
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>Adds stable diagnostic context to every event in one operation.</summary>
|
||||
public sealed class ContextNodeLog(INodeLog inner, params (string Key, object? Value)[] context) : INodeLog
|
||||
{
|
||||
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields) =>
|
||||
inner.Write(level, message, [.. context, .. fields]);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public static class FleetErrors
|
||||
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
|
||||
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
|
||||
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
|
||||
/// <summary>节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 CI 签名的更新清单里。</summary>
|
||||
public static readonly ErrorCode BinaryMismatch = new("fleet_binary_mismatch");
|
||||
|
||||
/// <summary>对方的版本低于要求的最低版本:等一会儿(对方可能正在更新)再握手。</summary>
|
||||
public static readonly ErrorCode VersionRequired = new("fleet_version_required");
|
||||
}
|
||||
@@ -9,12 +9,35 @@ public enum NodeLogLevel
|
||||
Error,
|
||||
}
|
||||
|
||||
/// <summary>节点日志:字段名统一为 logfmt(<c>ts level msg agent probe session task result err</c>)。逐包事件不记录,只累加计数。</summary>
|
||||
/// <summary>节点日志:字段名统一为 logfmt(<c>ts level msg agent dialer session task result err</c>)。逐包事件不记录,只累加计数。</summary>
|
||||
public interface INodeLog
|
||||
{
|
||||
void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields);
|
||||
}
|
||||
|
||||
/// <summary>One structured node log line.</summary>
|
||||
public sealed record NodeLogRecord(long AtUtcMs, string Level, string Message, Dictionary<string, string> Fields);
|
||||
|
||||
/// <summary>A retained log line: <paramref name="Id"/> grows with every line, <paramref name="Type"/> is a stable hash of the message text (the template), so counters can be sent without the text.</summary>
|
||||
public sealed record NodeLogEntry(long Id, uint Type, NodeLogRecord Record);
|
||||
|
||||
/// <summary>How many lines of one type and level were written since the last summary.</summary>
|
||||
public sealed record NodeLogTypeCount(uint Type, string Level, long Count);
|
||||
|
||||
/// <summary>What a node tells the control plane about its logs without sending any text: counters since the last summary and the newest line id.</summary>
|
||||
public sealed record NodeLogSummary(long LastId, long Info, long Warn, long Error, IReadOnlyList<NodeLogTypeCount> Types);
|
||||
|
||||
public sealed record NodeLogPage(IReadOnlyList<NodeLogEntry> Items, long LastId);
|
||||
|
||||
/// <summary>Bounded local log retention. The control plane gets counters in heartbeats and pulls the text when it needs it.</summary>
|
||||
public interface INodeLogSource
|
||||
{
|
||||
NodeLogSummary TakeSummary();
|
||||
|
||||
/// <summary>Lines with an id above <paramref name="afterId"/>, oldest first, at most <paramref name="limit"/>, at or above <paramref name="minLevel"/> (info, warn or error).</summary>
|
||||
NodeLogPage Read(long afterId, int limit, string? minLevel);
|
||||
}
|
||||
|
||||
public sealed class NullNodeLog : INodeLog
|
||||
{
|
||||
public static NullNodeLog Instance { get; } = new();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Fleet.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 版本号比较,给「要求的最低版本」用。认得 <c>主.次.修订</c>,可以带预发布后缀;开发构建 <c>0.4.2-dev.73-5245f03</c> 按 <c>dev.73</c> 的序号比较,
|
||||
/// 提交哈希不参与比较。同一个主次修订里,正式版高于任何预发布版。认不出来的版本号不拒绝(返回 false),免得一个奇怪的版本字符串把节点挡在门外。
|
||||
/// </summary>
|
||||
public static class NodeVersions
|
||||
{
|
||||
private readonly record struct Parsed(int Major, int Minor, int Patch, bool Prerelease, long Dev, string Suffix);
|
||||
|
||||
/// <summary><paramref name="actual"/> 低于 <paramref name="required"/> 返回 true;任何一个为空或无法解析返回 false。</summary>
|
||||
public static bool IsBelow(string? actual, string? required)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(required) || string.IsNullOrWhiteSpace(actual))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParse(actual, out Parsed have) || !TryParse(required, out Parsed need))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Compare(have, need) < 0;
|
||||
}
|
||||
|
||||
private static int Compare(Parsed a, Parsed b)
|
||||
{
|
||||
int core = (a.Major, a.Minor, a.Patch).CompareTo((b.Major, b.Minor, b.Patch));
|
||||
if (core != 0)
|
||||
{
|
||||
return core;
|
||||
}
|
||||
|
||||
if (a.Prerelease != b.Prerelease)
|
||||
{
|
||||
return a.Prerelease ? -1 : 1; // 正式版高于预发布版
|
||||
}
|
||||
|
||||
if (!a.Prerelease)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int dev = a.Dev.CompareTo(b.Dev);
|
||||
return dev != 0 ? dev : 0;
|
||||
}
|
||||
|
||||
private static bool TryParse(string text, out Parsed parsed)
|
||||
{
|
||||
parsed = default;
|
||||
string value = text.Trim().TrimStart('v');
|
||||
int plus = value.IndexOf('+', StringComparison.Ordinal);
|
||||
if (plus >= 0)
|
||||
{
|
||||
value = value[..plus];
|
||||
}
|
||||
|
||||
int dash = value.IndexOf('-', StringComparison.Ordinal);
|
||||
string core = dash < 0 ? value : value[..dash];
|
||||
string suffix = dash < 0 ? "" : value[(dash + 1)..];
|
||||
string[] parts = core.Split('.');
|
||||
if (parts.Length != 3
|
||||
|| !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out int major)
|
||||
|| !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out int minor)
|
||||
|| !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out int patch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long dev = 0;
|
||||
if (suffix.StartsWith("dev.", StringComparison.Ordinal))
|
||||
{
|
||||
string number = suffix[4..];
|
||||
int end = number.IndexOf('-', StringComparison.Ordinal);
|
||||
if (!long.TryParse(end < 0 ? number : number[..end], NumberStyles.None, CultureInfo.InvariantCulture, out dev))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
parsed = new Parsed(major, minor, patch, suffix.Length > 0, dev, suffix);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -51,16 +51,4 @@ public static class VarInt
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
|
||||
{
|
||||
if (source.Length < 4)
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Globalization;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Writes logs locally and keeps a bounded ring of recent lines. Heartbeats carry only counters (see <see cref="TakeSummary"/>);
|
||||
/// the text leaves the node when the control plane asks for it (see <see cref="Read"/>).
|
||||
/// </summary>
|
||||
public sealed class BufferedNodeLog : INodeLog, INodeLogSource
|
||||
{
|
||||
private const int Capacity = 2048;
|
||||
private const int MaxFields = 20;
|
||||
private const int MaxMessageLength = 512;
|
||||
private const int MaxFieldValueLength = 256;
|
||||
private const int MaxPageBytes = 96 << 10;
|
||||
private const int MaxSummaryTypes = 8;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly LinkedList<NodeLogEntry> _ring = [];
|
||||
private readonly Dictionary<(uint Type, string Level), long> _counts = [];
|
||||
private readonly INodeLog _local;
|
||||
private readonly TimeProvider _time;
|
||||
private long _lastId;
|
||||
private long _info;
|
||||
private long _warn;
|
||||
private long _error;
|
||||
|
||||
public BufferedNodeLog(INodeLog local, TimeProvider time)
|
||||
{
|
||||
_local = local;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
|
||||
{
|
||||
_local.Write(level, message, fields);
|
||||
|
||||
var safeFields = new Dictionary<string, string>(Math.Min(fields.Length, MaxFields), StringComparer.Ordinal);
|
||||
foreach ((string key, object? value) in fields.Take(MaxFields))
|
||||
{
|
||||
string safeValue = IsSensitiveKey(key) ? "[redacted]" : Convert.ToString(value, CultureInfo.InvariantCulture) ?? "";
|
||||
safeFields[key.Length <= 64 ? key : key[..64]] = Truncate(safeValue, MaxFieldValueLength);
|
||||
}
|
||||
|
||||
string levelName = level.ToString().ToLowerInvariant();
|
||||
var record = new NodeLogRecord(_time.GetUtcNow().ToUnixTimeMilliseconds(), levelName, Truncate(message, MaxMessageLength), safeFields);
|
||||
uint type = TypeOf(record.Message);
|
||||
lock (_gate)
|
||||
{
|
||||
_ring.AddLast(new NodeLogEntry(++_lastId, type, record));
|
||||
if (_ring.Count > Capacity)
|
||||
{
|
||||
_ring.RemoveFirst();
|
||||
}
|
||||
|
||||
_counts[(type, levelName)] = _counts.GetValueOrDefault((type, levelName)) + 1;
|
||||
switch (level)
|
||||
{
|
||||
case NodeLogLevel.Error:
|
||||
_error++;
|
||||
break;
|
||||
case NodeLogLevel.Warn:
|
||||
_warn++;
|
||||
break;
|
||||
default:
|
||||
_info++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public NodeLogSummary TakeSummary()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var summary = new NodeLogSummary(
|
||||
_lastId, _info, _warn, _error,
|
||||
[.. _counts.OrderByDescending(pair => pair.Value).Take(MaxSummaryTypes).Select(pair => new NodeLogTypeCount(pair.Key.Type, pair.Key.Level, pair.Value))]);
|
||||
_counts.Clear();
|
||||
_info = _warn = _error = 0;
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
public NodeLogPage Read(long afterId, int limit, string? minLevel)
|
||||
{
|
||||
int rank = LevelRank(minLevel);
|
||||
lock (_gate)
|
||||
{
|
||||
var items = new List<NodeLogEntry>(Math.Min(Math.Max(limit, 0), 200));
|
||||
int bytes = 0;
|
||||
foreach (NodeLogEntry entry in _ring)
|
||||
{
|
||||
if (entry.Id <= afterId || LevelRank(entry.Record.Level) < rank)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int entryBytes = 64 + entry.Record.Message.Length + entry.Record.Fields.Sum(pair => pair.Key.Length + pair.Value.Length + 8);
|
||||
if (items.Count >= Math.Clamp(limit, 0, 200) || (items.Count > 0 && bytes + entryBytes > MaxPageBytes))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
items.Add(entry);
|
||||
bytes += entryBytes;
|
||||
}
|
||||
|
||||
return new NodeLogPage(items, _lastId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The line's type: a stable 32-bit FNV-1a hash of its message text, so the same template always maps to the same number.</summary>
|
||||
public static uint TypeOf(string message)
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
foreach (char c in message)
|
||||
{
|
||||
hash = (hash ^ c) * 16777619;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static int LevelRank(string? level) => level switch
|
||||
{
|
||||
"error" => 2,
|
||||
"warn" => 1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static bool IsSensitiveKey(string key)
|
||||
{
|
||||
string lower = key.ToLowerInvariant();
|
||||
return lower.Contains("token", StringComparison.Ordinal)
|
||||
|| lower.Contains("secret", StringComparison.Ordinal)
|
||||
|| lower.Contains("password", StringComparison.Ordinal)
|
||||
|| lower.Contains("private", StringComparison.Ordinal)
|
||||
|| lower.Contains("signature", StringComparison.Ordinal)
|
||||
|| lower.Contains("authorization", StringComparison.Ordinal)
|
||||
|| lower.Contains("cookie", StringComparison.Ordinal)
|
||||
|| lower == "mac";
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength) => value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
@@ -156,6 +156,9 @@ public static class ServiceShell
|
||||
log.Info("stopping", ("result", "SIGHUP"));
|
||||
cts.Cancel();
|
||||
});
|
||||
// 有序退出卡住(比如某个已经死掉的会话收不到对端的关闭回复)时不能一直挂着:supervisor 的 stop 超时之前自己退出,由它重新拉起
|
||||
using var exited = new CancellationTokenSource();
|
||||
using CancellationTokenRegistration deadline = cts.Token.Register(() => _ = ExitIfStuckAsync(time ?? TimeProvider.System, log, exited.Token));
|
||||
Task trimmer = TrimHeapAsync(cts.Token);
|
||||
Task scheduled = restart is null ? Task.CompletedTask : WaitForRestartAsync(restart, time ?? TimeProvider.System, log, cts);
|
||||
try
|
||||
@@ -171,9 +174,28 @@ public static class ServiceShell
|
||||
await cts.CancelAsync();
|
||||
await trimmer;
|
||||
await scheduled;
|
||||
await exited.CancelAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>退出请求发出之后给有序退出的时间(要短于容器的 StopTimeout,否则 supervisor 会先强杀)。</summary>
|
||||
public static readonly TimeSpan ShutdownGrace = TimeSpan.FromSeconds(10);
|
||||
|
||||
private static async Task ExitIfStuckAsync(TimeProvider time, INodeLog log, CancellationToken exited)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(ShutdownGrace, time, exited);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return; // 正常退出了
|
||||
}
|
||||
|
||||
log.Warn("orderly shutdown did not finish in time, exiting", ("grace_s", (long)ShutdownGrace.TotalSeconds));
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
private static async Task WaitForRestartAsync(RestartController restart, TimeProvider time, INodeLog log, CancellationTokenSource cts)
|
||||
{
|
||||
while (!cts.IsCancellationRequested)
|
||||
@@ -181,7 +203,7 @@ public static class ServiceShell
|
||||
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
|
||||
CancellationToken changed = restart.ChangeToken;
|
||||
RestartSchedule? schedule = restart.Current;
|
||||
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed);
|
||||
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed, restart.NowToken);
|
||||
try
|
||||
{
|
||||
if (schedule is null)
|
||||
@@ -196,6 +218,13 @@ public static class ServiceShell
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (restart.NowReason is { } reason)
|
||||
{
|
||||
log.Info("stopping", ("result", reason));
|
||||
await cts.CancelAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
|
||||
}
|
||||
|
||||
@@ -258,7 +287,7 @@ public static class NodeConfig
|
||||
|
||||
/// <summary>
|
||||
/// 常驻业务进程里唯一允许写文件的地方,而且只能写配置/身份目录(原子替换,类 Unix 上权限 0600)。
|
||||
/// 例如 Probe 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。
|
||||
/// 例如 拨号方 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。
|
||||
/// </summary>
|
||||
public sealed class ConfigStore
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ using Fleet.Core;
|
||||
namespace Fleet.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
|
||||
/// 节点自己要求有属主(passkey):拨号方和监听方共用这一份,启动时检查。
|
||||
/// <para>
|
||||
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
|
||||
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
|
||||
|
||||
@@ -8,7 +8,9 @@ public sealed class RestartController : IDisposable
|
||||
private readonly RestartSchedule? _local;
|
||||
private readonly object _gate = new();
|
||||
private RestartSchedule? _remote;
|
||||
private readonly CancellationTokenSource _now = new();
|
||||
private CancellationTokenSource _changed = new();
|
||||
private string? _nowReason;
|
||||
|
||||
public RestartController(RestartSchedule? local = null)
|
||||
{
|
||||
@@ -41,6 +43,32 @@ public sealed class RestartController : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>应用要求现在就有序退出(比如管理员请求更新);退出的原因,没有就是 null。</summary>
|
||||
public string? NowReason
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _nowReason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>应用要求现在就退出的令牌。</summary>
|
||||
public CancellationToken NowToken => _now.Token;
|
||||
|
||||
/// <summary>要求进程现在就有序退出,由服务管理器拉起;不管有没有设置定时重启。</summary>
|
||||
public void RequestNow(string reason)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_nowReason ??= reason;
|
||||
}
|
||||
|
||||
_now.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>面板下发的设置;为空表示面板关闭了定时重启。返回 false 表示文本不合法(节点忽略它)。</summary>
|
||||
public bool SetRemote(string? text)
|
||||
{
|
||||
@@ -68,6 +96,7 @@ public sealed class RestartController : IDisposable
|
||||
lock (_gate)
|
||||
{
|
||||
_changed.Dispose();
|
||||
_now.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public sealed partial class TrustFileContext : JsonSerializerContext;
|
||||
/// <summary>
|
||||
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
|
||||
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
|
||||
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
|
||||
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/security/access.md 第 9 节)。
|
||||
/// </summary>
|
||||
public sealed class TrustAnchor : IServerVerifier
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
|
||||
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
|
||||
|
||||
/// <summary>
|
||||
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
|
||||
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/security/access.md 第 8 节。
|
||||
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
|
||||
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
|
||||
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Fleet.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
|
||||
/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
|
||||
/// 面板用它验证 passkey 登录,节点验证操作员集合的签名也用它(docs/security/access.md 第 8 节)。
|
||||
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
|
||||
/// </summary>
|
||||
public static class WebAuthnAssertion
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
@@ -49,7 +52,10 @@ public sealed class FleetLinkOptions
|
||||
|
||||
public string NodeVersion { get; init; } = "0.0.0";
|
||||
|
||||
/// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:udp_echo,tcp_echo</c>。</summary>
|
||||
/// <summary>服务端至少要是哪个版本(每次握手重新取);空表示不要求。不够就关掉这条连接,等 <see cref="VersionRetry"/> 再握手,服务端可能正在更新。</summary>
|
||||
public Func<string?>? MinServerVersion { get; init; }
|
||||
|
||||
/// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:quic_ping,tcp_ping</c>。</summary>
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = [];
|
||||
|
||||
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
|
||||
@@ -58,6 +64,12 @@ public sealed class FleetLinkOptions
|
||||
/// <summary>附加到 <c>fleet.heartbeat</c> 的进程内计数(不能包含主机信息)。</summary>
|
||||
public Func<JsonElement?>? HeartbeatExtra { get; init; }
|
||||
|
||||
/// <summary>Optional reason for an orderly client shutdown, so the server can distinguish updates from service stops.</summary>
|
||||
public Func<string>? ShutdownReason { get; init; }
|
||||
|
||||
/// <summary>连上服务器的整个过程(TCP、TLS、升级)最多等多久;卡住就放弃,由外层退避重试。</summary>
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(20);
|
||||
|
||||
public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public int MaxFrameBytes { get; init; } = 16 << 20;
|
||||
@@ -72,9 +84,6 @@ public sealed class FleetLinkOptions
|
||||
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
|
||||
/// </summary>
|
||||
public IServerVerifier? ServerVerifier { get; init; }
|
||||
|
||||
/// <summary>握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。</summary>
|
||||
public Func<BinaryAttestation?>? Attestation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -94,6 +103,13 @@ public sealed class FleetLink
|
||||
private volatile RpcSession<LinkContext>? _session;
|
||||
private volatile bool _stopped;
|
||||
|
||||
/// <summary>版本不够时重新握手的间隔:固定,不退避(对方更新完马上就能连上)。</summary>
|
||||
public static readonly TimeSpan VersionRetry = TimeSpan.FromSeconds(5);
|
||||
|
||||
private static readonly TimeSpan s_tcpTimeout = TimeSpan.FromSeconds(4);
|
||||
private TimeSpan? _fixedRetry;
|
||||
private int _failedConnects;
|
||||
|
||||
public FleetLink(FleetLinkOptions options, RpcRouter<LinkContext> router, IEnumerable<IStateHandler> handlers, Outbox outbox, TimeProvider time, INodeLog log)
|
||||
{
|
||||
_options = options;
|
||||
@@ -140,7 +156,7 @@ public sealed class FleetLink
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is WebSocketException or IOException or HttpRequestException or InvalidOperationException or TimeoutException)
|
||||
catch (Exception ex)
|
||||
{
|
||||
reason = ex.Message;
|
||||
}
|
||||
@@ -157,12 +173,68 @@ public sealed class FleetLink
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan delay = _options.Backoff.Next();
|
||||
TimeSpan delay = _fixedRetry ?? _options.Backoff.Next();
|
||||
_fixedRetry = null;
|
||||
_log.Warn("link down, retrying", ("err", reason), ("retry_ms", (long)delay.TotalMilliseconds));
|
||||
await Task.Delay(delay, _time, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 连上面板:整个握手(TCP、TLS、升级)限时,卡住就放弃重试;每个地址的 TCP 连接也单独限时。
|
||||
/// 失败之后下一次换着先试另一个地址族(IPv6 / IPv4):有的网络上到某个地址族的 TCP 能连上、之后的 TLS 却被卡住,换一个往往就通。
|
||||
/// </summary>
|
||||
private async Task ConnectAsync(ClientWebSocket socket, Uri uri, CancellationToken ct)
|
||||
{
|
||||
int attempt = Volatile.Read(ref _failedConnects);
|
||||
using var handler = new SocketsHttpHandler { ConnectCallback = (context, token) => ConnectTcpAsync(context, attempt, token) };
|
||||
using var invoker = new HttpMessageInvoker(handler, disposeHandler: false);
|
||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(_options.ConnectTimeout);
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(uri, invoker, timeout.Token);
|
||||
Volatile.Write(ref _failedConnects, 0);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
Interlocked.Increment(ref _failedConnects);
|
||||
throw new TimeoutException("connecting to the server timed out");
|
||||
}
|
||||
catch
|
||||
{
|
||||
Interlocked.Increment(ref _failedConnects);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask<Stream> ConnectTcpAsync(SocketsHttpConnectionContext context, int attempt, CancellationToken ct)
|
||||
{
|
||||
IPAddress[] addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, ct);
|
||||
// 第一次按 DNS 的顺序;之后奇数次先试 IPv6,偶数次先试 IPv4
|
||||
IEnumerable<IPAddress> ordered = attempt == 0 ? addresses
|
||||
: addresses.OrderBy(a => (a.AddressFamily == AddressFamily.InterNetworkV6) == (attempt % 2 == 1) ? 0 : 1);
|
||||
Exception? last = null;
|
||||
foreach (IPAddress address in ordered)
|
||||
{
|
||||
var tcp = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
using CancellationTokenSource limit = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
limit.CancelAfter(s_tcpTimeout);
|
||||
try
|
||||
{
|
||||
await tcp.ConnectAsync(new IPEndPoint(address, context.DnsEndPoint.Port), limit.Token);
|
||||
return new NetworkStream(tcp, ownsSocket: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or OperationCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
tcp.Dispose();
|
||||
last = ex;
|
||||
}
|
||||
}
|
||||
|
||||
throw last ?? new SocketException((int)SocketError.HostNotFound);
|
||||
}
|
||||
|
||||
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
|
||||
public bool IsSecure => _secure && IsConnected;
|
||||
|
||||
@@ -172,7 +244,7 @@ public sealed class FleetLink
|
||||
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15);
|
||||
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
||||
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
||||
await socket.ConnectAsync(builder.Uri, ct);
|
||||
await ConnectAsync(socket, builder.Uri, ct);
|
||||
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes));
|
||||
|
||||
string? failure = await AuthenticateAsync(transport, ct);
|
||||
@@ -184,7 +256,8 @@ public sealed class FleetLink
|
||||
_secure = transport.Secure;
|
||||
|
||||
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
||||
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// Keep the active WebSocket receive alive until the planned shutdown marker and Close frame have been sent.
|
||||
using var sessionCts = new CancellationTokenSource();
|
||||
Task run = session.RunAsync(sessionCts.Token);
|
||||
try
|
||||
{
|
||||
@@ -195,10 +268,22 @@ public sealed class FleetLink
|
||||
NowUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||
Meta = _options.HelloMeta?.Invoke(),
|
||||
};
|
||||
Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, sessionCts.Token);
|
||||
Result<HelloResult> helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, ct);
|
||||
if (!helloResult.IsSuccess)
|
||||
{
|
||||
return "hello failed: " + helloResult.Error.Code;
|
||||
if (helloResult.Error.Code == FleetErrors.VersionRequired)
|
||||
{
|
||||
_fixedRetry = VersionRetry; // 我们的版本低于服务端要求的:等自己更新,每 5 秒再试一次
|
||||
}
|
||||
|
||||
return "hello failed: " + helloResult.Error.Code + (_fixedRetry is null ? "" : ": " + helloResult.Error.Message);
|
||||
}
|
||||
|
||||
string? requiredServer = _options.MinServerVersion?.Invoke();
|
||||
if (NodeVersions.IsBelow(helloResult.Value.ServerVersion, requiredServer))
|
||||
{
|
||||
_fixedRetry = VersionRetry; // 服务端版本低于我们要求的(它可能正在更新):不发任何东西,5 秒后重新握手
|
||||
return $"server version {helloResult.Value.ServerVersion} is below the required {requiredServer}";
|
||||
}
|
||||
|
||||
Outbox.ResetSent();
|
||||
@@ -211,31 +296,39 @@ public sealed class FleetLink
|
||||
await OnConnected(session, sessionCts.Token);
|
||||
}
|
||||
|
||||
Task finished = await Task.WhenAny(run, pump, heartbeat);
|
||||
Task cancellation = Task.Delay(Timeout.Infinite, ct);
|
||||
Task finished = await Task.WhenAny(run, pump, heartbeat, cancellation);
|
||||
string finishedReason = DescribeFinishedTask(finished, run, pump, heartbeat, session.TransportFailure);
|
||||
_session = null; // 链路已经结束:上报方不能再往这条会话上写,收尾卡住时也一样
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1), _time);
|
||||
string reason = _options.ShutdownReason?.Invoke() ?? "stopping";
|
||||
await session.NotifyAsync(FleetMethods.Stopping, new StoppingParams { Reason = reason }, FleetRpcContext.Default.StoppingParams, closeTimeout.Token);
|
||||
await session.CloseAsync(reason, closeTimeout.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException or InvalidOperationException)
|
||||
{
|
||||
// The socket may already be gone; shutdown must still complete.
|
||||
}
|
||||
}
|
||||
|
||||
await sessionCts.CancelAsync();
|
||||
await Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat));
|
||||
return finished == run ? "closed by server" : "link task failed";
|
||||
await Bounded.WaitAsync(Task.WhenAll(Quiet(run), Quiet(pump), Quiet(heartbeat)), Bounded.Teardown, _time);
|
||||
return finishedReason;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_session = null;
|
||||
await sessionCts.CancelAsync();
|
||||
await Quiet(run);
|
||||
await session.DisposeAsync();
|
||||
await Bounded.WaitAsync(Quiet(run), Bounded.Teardown, _time);
|
||||
await Bounded.WaitAsync(session.DisposeAsync().AsTask(), Bounded.Teardown, _time);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
||||
private void Attest(AuthParams auth)
|
||||
{
|
||||
if (_options.Attestation?.Invoke() is { } attestation)
|
||||
{
|
||||
auth.Binary = attestation.Sha256;
|
||||
auth.Release = attestation.Manifest;
|
||||
auth.ReleaseSignature = attestation.Signature;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
||||
@@ -270,7 +363,6 @@ public sealed class FleetLink
|
||||
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
|
||||
NodeEphemeral = nodeEphemeralText,
|
||||
};
|
||||
Attest(authParams);
|
||||
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
|
||||
@@ -278,7 +370,6 @@ public sealed class FleetLink
|
||||
else
|
||||
{
|
||||
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
|
||||
Attest(authParams);
|
||||
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
|
||||
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||
}
|
||||
@@ -393,9 +484,29 @@ public sealed class FleetLink
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Session cancellation is expected during reconnect and shutdown.
|
||||
}
|
||||
catch (Exception ex) when (ex is WebSocketException or IOException or ObjectDisposedException or TimeoutException or InvalidOperationException)
|
||||
{
|
||||
// 会话结束时的正常异常
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeFinishedTask(Task finished, Task run, Task pump, Task heartbeat, string? transportFailure)
|
||||
{
|
||||
if (finished.IsFaulted)
|
||||
{
|
||||
string taskName = finished == run ? "receive" : finished == pump ? "outbox send" : "heartbeat";
|
||||
return $"{taskName} failed: {finished.Exception?.GetBaseException().Message ?? "unknown error"}";
|
||||
}
|
||||
|
||||
if (finished == run && transportFailure is not null)
|
||||
{
|
||||
return "transport send failed: " + transportFailure;
|
||||
}
|
||||
|
||||
return finished == run ? "closed by server" : "link task stopped";
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,23 @@ public sealed class PeerListenerOptions
|
||||
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
|
||||
public required Func<string, byte[]?> LookupPeerKey { get; init; }
|
||||
|
||||
/// <summary>预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
|
||||
/// <summary>预握手敲门:每个连接先过这一关(名册里的 拨号方 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
|
||||
public required KnockGate Knock { get; init; }
|
||||
|
||||
public int MaxFrameBytes { get; init; } = 8 << 20;
|
||||
|
||||
public int MaxSessions { get; init; } = 8;
|
||||
/// <summary>
|
||||
/// 同时在线的会话数上限,只数已经通过敲门的对端。每个拨号方每个地址族一个会话,所以要容得下「名册里的节点数 × 2」。
|
||||
/// 之前这个上限连还没敲门的连接一起数:节点多起来之后,已建立的会话把名额占满,后到的拨号方(哪怕在名册里)一连上就被断开,
|
||||
/// 而且一直连不上,重试只会得到同样的结果。
|
||||
/// </summary>
|
||||
public int MaxSessions { get; init; } = 64;
|
||||
|
||||
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
/// <summary>还没通过敲门的连接数上限(陌生人、扫描器、还在握手的):敲门有读超时,占着名额的时间有限,但它们不能挤占已通过敲门的会话。</summary>
|
||||
public int MaxPending { get; init; } = 64;
|
||||
|
||||
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary>
|
||||
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,6 +52,7 @@ public sealed class PeerListener
|
||||
private readonly INodeLog _log;
|
||||
private readonly TimeProvider _time;
|
||||
private int _sessions;
|
||||
private int _pending;
|
||||
|
||||
public PeerListener(PeerListenerOptions options, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> onSession, INodeLog log, TimeProvider time)
|
||||
{
|
||||
@@ -73,9 +83,9 @@ public sealed class PeerListener
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client = await listener.AcceptTcpClientAsync(ct);
|
||||
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
|
||||
if (Interlocked.Increment(ref _pending) > _options.MaxPending)
|
||||
{
|
||||
Interlocked.Decrement(ref _sessions);
|
||||
Interlocked.Decrement(ref _pending);
|
||||
client.Dispose();
|
||||
continue;
|
||||
}
|
||||
@@ -95,6 +105,11 @@ public sealed class PeerListener
|
||||
|
||||
private async Task HandleAsync(TcpClient client, CancellationToken ct)
|
||||
{
|
||||
bool admitted = false;
|
||||
bool counted = false;
|
||||
string phase = "knock";
|
||||
long started = _time.GetTimestamp();
|
||||
EndPoint? remote = client.Client.RemoteEndPoint;
|
||||
try
|
||||
{
|
||||
client.NoDelay = true;
|
||||
@@ -107,19 +122,36 @@ public sealed class PeerListener
|
||||
return;
|
||||
}
|
||||
|
||||
admitted = true;
|
||||
Interlocked.Decrement(ref _pending);
|
||||
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
|
||||
{
|
||||
Interlocked.Decrement(ref _sessions);
|
||||
_log.Warn("peer session limit reached", ("remote", remote), ("limit", _options.MaxSessions));
|
||||
return;
|
||||
}
|
||||
|
||||
counted = true;
|
||||
phase = "tls";
|
||||
_log.Info("peer knock accepted", ("remote", remote), ("phase", phase));
|
||||
var tls = new SslStream(stream, leaveInnerStreamOpen: false);
|
||||
await tls.AuthenticateAsServerAsync(
|
||||
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
|
||||
linked.Token);
|
||||
phase = "peer_auth";
|
||||
_log.Info("peer TLS established", ("remote", remote), ("phase", phase), ("tls", tls.SslProtocol));
|
||||
var transport = new TlsFrameTransport(tls, _options.MaxFrameBytes);
|
||||
PeerSessionInfo? info = await HandshakeAsync(transport, client.Client.RemoteEndPoint, linked.Token);
|
||||
if (info is null)
|
||||
{
|
||||
_log.Warn("peer authentication rejected", ("remote", remote), ("phase", phase));
|
||||
await transport.DisposeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Info("peer session", ("probe", info.PeerId), ("result", "established"));
|
||||
phase = "control_session";
|
||||
_log.Info("peer session", ("dialer", info.PeerId), ("result", "established"), ("remote", remote),
|
||||
("elapsed_ms", (long)_time.GetElapsedTime(started).TotalMilliseconds));
|
||||
await using (transport)
|
||||
{
|
||||
await _onSession(info, transport, ct);
|
||||
@@ -127,12 +159,27 @@ public sealed class PeerListener
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
// 握手失败或连接断开:这个会话结束,监听继续
|
||||
// Invalid knocks stay silent. Only peers that passed admission get diagnostics.
|
||||
if (admitted && !ct.IsCancellationRequested)
|
||||
{
|
||||
_log.Warn("peer control connection failed", ("remote", remote), ("phase", phase),
|
||||
("elapsed_ms", (long)_time.GetElapsedTime(started).TotalMilliseconds),
|
||||
("exception", ex.GetType().Name), ("err", ex.Message),
|
||||
("socket_error", ex is SocketException socket ? socket.SocketErrorCode : null));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
Interlocked.Decrement(ref _sessions);
|
||||
if (!admitted)
|
||||
{
|
||||
Interlocked.Decrement(ref _pending);
|
||||
}
|
||||
|
||||
if (counted)
|
||||
{
|
||||
Interlocked.Decrement(ref _sessions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,8 +255,9 @@ public sealed class PeerListener
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
|
||||
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 15 秒后每 5 秒探测,连续 3 次无响应就断开,约 30 秒),主机丢失或路径被切断时连接不会一直挂着。</summary>
|
||||
/// <summary>发起点对点会话(拨号方 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
|
||||
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着;
|
||||
/// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。</summary>
|
||||
internal static class PeerSockets
|
||||
{
|
||||
public static void EnableKeepAlive(Socket socket)
|
||||
@@ -217,9 +265,9 @@ internal static class PeerSockets
|
||||
try
|
||||
{
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 15);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 5);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10);
|
||||
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 6);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException)
|
||||
{
|
||||
@@ -228,27 +276,51 @@ internal static class PeerSockets
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>点对点会话的时间常数,拨号方和监听方两侧共用。</summary>
|
||||
public static class PeerTimings
|
||||
{
|
||||
/// <summary>建立会话(连接、敲门、TLS、挑战应答)的总时限。</summary>
|
||||
public static readonly TimeSpan Handshake = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public static class PeerConnector
|
||||
{
|
||||
public static Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
|
||||
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct) =>
|
||||
ConnectAsync(host, port, agentPublicKey, identity, peerId, version, maxFrameBytes, time, NullNodeLog.Instance, ct);
|
||||
|
||||
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
|
||||
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
|
||||
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, INodeLog log, CancellationToken ct,
|
||||
Action<string>? onPhase = null)
|
||||
{
|
||||
// 证书按公钥的指纹固定;敲门密钥由双方身份密钥派生
|
||||
byte[] expectedSpkiSha256 = NodeIdentity.Fingerprint(agentPublicKey);
|
||||
TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient();
|
||||
client.NoDelay = true;
|
||||
PeerSockets.EnableKeepAlive(client.Client);
|
||||
string phase = "tcp_connect";
|
||||
onPhase?.Invoke(phase);
|
||||
long started = time.GetTimestamp();
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
|
||||
using var timeout = new CancellationTokenSource(PeerTimings.Handshake, time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
await client.ConnectAsync(host, port, linked.Token);
|
||||
log.Info("peer TCP connected", ("remote", client.Client.RemoteEndPoint), ("local", client.Client.LocalEndPoint),
|
||||
("phase", phase), ("elapsed_ms", (long)time.GetElapsedTime(started).TotalMilliseconds));
|
||||
phase = "knock_send";
|
||||
onPhase?.Invoke(phase);
|
||||
Stream stream = client.GetStream();
|
||||
await stream.WriteAsync(Knock.Build(Knock.DeriveKey(identity, agentPublicKey), time.GetUtcNow().ToUnixTimeMilliseconds()), linked.Token);
|
||||
phase = "tls";
|
||||
onPhase?.Invoke(phase);
|
||||
var tls = new SslStream(
|
||||
stream, leaveInnerStreamOpen: false,
|
||||
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
|
||||
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
|
||||
log.Info("peer TLS established", ("phase", phase), ("tls", tls.SslProtocol), ("elapsed_ms", (long)time.GetElapsedTime(started).TotalMilliseconds));
|
||||
phase = "peer_auth";
|
||||
onPhase?.Invoke(phase);
|
||||
var transport = new TlsFrameTransport(tls, maxFrameBytes);
|
||||
|
||||
RpcFrame? first = await transport.ReceiveAsync(linked.Token);
|
||||
@@ -279,10 +351,19 @@ public static class PeerConnector
|
||||
throw new InvalidOperationException("session id mismatch: the key agreement did not produce the same credentials");
|
||||
}
|
||||
|
||||
log.Info("peer authentication established", ("phase", phase), ("elapsed_ms", (long)time.GetElapsedTime(started).TotalMilliseconds));
|
||||
return (new PeerSessionInfo(peerId, credentials, client.Client.RemoteEndPoint), transport);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
bool timedOut = ex is OperationCanceledException;
|
||||
log.Warn("peer control handshake failed", ("phase", phase), ("remote", host), ("port", port),
|
||||
("elapsed_ms", (long)time.GetElapsedTime(started).TotalMilliseconds), ("timeout_ms", PeerTimings.Handshake.TotalMilliseconds),
|
||||
("exception", timedOut ? "Timeout" : ex.GetType().Name), ("err", timedOut ? $"{phase} did not finish within {PeerTimings.Handshake.TotalSeconds:0}s" : ex.Message),
|
||||
("socket_error", ex is SocketException socket ? socket.SocketErrorCode : null));
|
||||
}
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Fleet.Link;
|
||||
/// <summary>WebSocket 传输(节点 → 服务端):文本帧是 JSON-RPC,二进制帧是发件箱。</summary>
|
||||
public sealed class WsTransport : IRpcTransport
|
||||
{
|
||||
private static readonly TimeSpan s_sendTimeout = TimeSpan.FromSeconds(20);
|
||||
private readonly WebSocket _socket;
|
||||
private readonly int _maxFrameBytes;
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
@@ -19,14 +20,32 @@ public sealed class WsTransport : IRpcTransport
|
||||
|
||||
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
|
||||
{
|
||||
await _sendLock.WaitAsync(ct);
|
||||
using var timeout = new CancellationTokenSource(s_sendTimeout);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
bool lockTaken = false;
|
||||
try
|
||||
{
|
||||
await _socket.SendAsync(frame.Data, frame.Kind == FrameKind.Text ? WebSocketMessageType.Text : WebSocketMessageType.Binary, endOfMessage: true, ct);
|
||||
await _sendLock.WaitAsync(linked.Token);
|
||||
lockTaken = true;
|
||||
if (_socket.State != WebSocketState.Open)
|
||||
{
|
||||
throw new WebSocketException($"WebSocket is not open (state: {_socket.State})");
|
||||
}
|
||||
|
||||
await _socket.SendAsync(frame.Data, frame.Kind == FrameKind.Text ? WebSocketMessageType.Text : WebSocketMessageType.Binary, endOfMessage: true, linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
// A wedged send otherwise holds the lock and prevents heartbeat/reconnect from making progress.
|
||||
_socket.Abort();
|
||||
throw new TimeoutException($"WebSocket send timed out after {s_sendTimeout.TotalSeconds:0} seconds");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
if (lockTaken)
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +112,13 @@ public sealed class WsTransport : IRpcTransport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TLS 帧传输(Probe → Agent):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
|
||||
/// TLS 帧传输(拨号方 → 监听方):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
|
||||
/// 比 WebSocket 轻得多,Agent 不需要内置 HTTP 服务端。
|
||||
/// </summary>
|
||||
public sealed class TlsFrameTransport : IRpcTransport
|
||||
{
|
||||
private const int HeaderBytes = 5;
|
||||
private static readonly TimeSpan s_sendTimeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
private readonly Stream _stream;
|
||||
private readonly int _maxFrameBytes;
|
||||
@@ -120,16 +140,29 @@ public sealed class TlsFrameTransport : IRpcTransport
|
||||
byte[] header = new byte[HeaderBytes];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)frame.Data.Length);
|
||||
header[4] = (byte)frame.Kind;
|
||||
await _sendLock.WaitAsync(ct);
|
||||
// 对端黑洞时写会一直挂着并占着发送锁,心跳、时钟同步和重连都跟着卡住:超时就掐掉这条连接
|
||||
using var timeout = new CancellationTokenSource(s_sendTimeout);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
bool lockTaken = false;
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(header, ct);
|
||||
await _stream.WriteAsync(frame.Data, ct);
|
||||
await _stream.FlushAsync(ct);
|
||||
await _sendLock.WaitAsync(linked.Token);
|
||||
lockTaken = true;
|
||||
await _stream.WriteAsync(header, linked.Token);
|
||||
await _stream.WriteAsync(frame.Data, linked.Token);
|
||||
await _stream.FlushAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
await _stream.DisposeAsync();
|
||||
throw new TimeoutException($"peer send timed out after {s_sendTimeout.TotalSeconds:0} seconds");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
if (lockTaken)
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ public static class FleetMethods
|
||||
public const string Auth = "fleet.auth";
|
||||
public const string Hello = "fleet.hello";
|
||||
public const string Heartbeat = "fleet.heartbeat";
|
||||
public const string Stopping = "fleet.stopping";
|
||||
public const string StateSync = "fleet.state.sync";
|
||||
public const string OutboxAck = "fleet.outbox.ack";
|
||||
public const string Revoked = "fleet.revoked";
|
||||
@@ -17,7 +18,7 @@ public static class FleetMethods
|
||||
|
||||
/// <summary>
|
||||
/// 框架自己的方法声明,两个平面各一份。认证和握手阶段由连接代码直接处理的(<c>Inline</c>)不经过路由器。
|
||||
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 Agent 发给 Probe。
|
||||
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 监听方发给拨号方。
|
||||
/// </summary>
|
||||
public static class FleetMethodTable
|
||||
{
|
||||
@@ -28,6 +29,7 @@ public static class FleetMethodTable
|
||||
MethodSpec.Request(FleetMethods.Auth, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Request(FleetMethods.Hello, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Notification(FleetMethods.Heartbeat, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Notification(FleetMethods.Stopping, RpcSide.Node).AsInline(),
|
||||
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Server),
|
||||
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Server),
|
||||
MethodSpec.Notification(FleetMethods.Revoked, RpcSide.Server),
|
||||
@@ -40,12 +42,6 @@ public static class FleetMethodTable
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
|
||||
public static class FleetChannels
|
||||
{
|
||||
public const byte FirstProductChannel = 16;
|
||||
}
|
||||
|
||||
public sealed class ChallengeParams
|
||||
{
|
||||
[JsonPropertyName("nonce")]
|
||||
@@ -75,17 +71,6 @@ public sealed class AuthParams
|
||||
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
|
||||
[JsonPropertyName("node_eph")]
|
||||
public string? NodeEphemeral { get; set; }
|
||||
|
||||
/// <summary>正在运行的二进制的 SHA-256(小写十六进制):服务端在第一次握手时绑定,之后核对。旧节点没有这个字段。</summary>
|
||||
[JsonPropertyName("binary")]
|
||||
public string? Binary { get; set; }
|
||||
|
||||
/// <summary>安装这个二进制时宿主机更新器验过的 CI 签名清单(原文)和签名(base64):服务端用 CI 公钥验过,才相信它列出的官方哈希。</summary>
|
||||
[JsonPropertyName("release")]
|
||||
public string? Release { get; set; }
|
||||
|
||||
[JsonPropertyName("release_sig")]
|
||||
public string? ReleaseSignature { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthResult
|
||||
@@ -106,7 +91,7 @@ public sealed class HelloParams
|
||||
[JsonPropertyName("now_utc_ms")]
|
||||
public long NowUtcMs { get; set; }
|
||||
|
||||
/// <summary>产品自定义的元数据(例如 MoShiro 的 tls_spki、probe_ports、local_limits 摘要)。</summary>
|
||||
/// <summary>产品自定义的元数据(例如 MoShiro 的 tls_spki、listen 信息、local_limits 摘要)。</summary>
|
||||
[JsonPropertyName("meta")]
|
||||
public JsonElement? Meta { get; set; }
|
||||
}
|
||||
@@ -119,6 +104,10 @@ public sealed class HelloResult
|
||||
/// <summary>节点时钟 − 服务端时钟(毫秒,未扣除网络往返)。</summary>
|
||||
[JsonPropertyName("clock_offset_ms")]
|
||||
public long ClockOffsetMs { get; set; }
|
||||
|
||||
/// <summary>服务端的版本:节点要求了最低服务端版本时,用它判断。</summary>
|
||||
[JsonPropertyName("server_version")]
|
||||
public string? ServerVersion { get; set; }
|
||||
}
|
||||
|
||||
public sealed class HeartbeatParams
|
||||
@@ -173,6 +162,12 @@ public sealed class RevokedParams
|
||||
public string Reason { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class StoppingParams
|
||||
{
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = "stopping";
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(RpcMessage))]
|
||||
[JsonSerializable(typeof(ChallengeParams))]
|
||||
@@ -181,6 +176,7 @@ public sealed class RevokedParams
|
||||
[JsonSerializable(typeof(HelloParams))]
|
||||
[JsonSerializable(typeof(HelloResult))]
|
||||
[JsonSerializable(typeof(HeartbeatParams))]
|
||||
[JsonSerializable(typeof(StoppingParams))]
|
||||
[JsonSerializable(typeof(StateSyncParams))]
|
||||
[JsonSerializable(typeof(StateSyncResult))]
|
||||
[JsonSerializable(typeof(OutboxAckParams))]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方(Probe)和监听方(Agent)。</summary>
|
||||
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方和监听方。</summary>
|
||||
public enum RpcSide
|
||||
{
|
||||
Server,
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
|
||||
namespace Fleet.Rpc;
|
||||
|
||||
/// <summary>
|
||||
/// 节点间点对点会话的握手(Probe → Agent,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥,
|
||||
/// 节点间点对点会话的握手(拨号方 → 监听方,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥,
|
||||
/// 经过 HKDF 派生出数据面使用的 <c>session_id</c> 与 <c>session_key</c>(所以不需要单独的“下发密钥”方法)。
|
||||
/// </summary>
|
||||
public static class PeerMethods
|
||||
|
||||
@@ -11,7 +11,7 @@ public readonly record struct RpcViolation(string Method, string Reason);
|
||||
public readonly record struct RpcDispatch(RpcMessage? Response, RpcViolation? Violation);
|
||||
|
||||
/// <summary>
|
||||
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、Probe 和 Agent 的入站分发都经过这里。
|
||||
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、拨号方和监听方 的入站分发都经过这里。
|
||||
/// <typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。路由器知道自己在连接的哪一侧(<see cref="Side"/>)和声明表,
|
||||
/// 注册处理函数时核对方向、类型和角色,分发时拒绝没有声明或不该由对方发起的方法,发送时核对自己有没有资格发起。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading.Channels;
|
||||
@@ -16,7 +17,7 @@ public readonly record struct RpcFrame(FrameKind Kind, ReadOnlyMemory<byte> Data
|
||||
|
||||
/// <summary>
|
||||
/// 一条双向的、保序的消息通道:文本帧承载 JSON-RPC,二进制帧承载发件箱。
|
||||
/// 节点到服务端用 WebSocket,Probe 到 Agent 用 TLS 帧;会话引擎对两者一视同仁。
|
||||
/// 节点到服务端用 WebSocket,拨号方到监听方 用 TLS 帧;会话引擎对两者一视同仁。
|
||||
/// </summary>
|
||||
public interface IRpcTransport : IAsyncDisposable
|
||||
{
|
||||
@@ -49,6 +50,7 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
private readonly IRpcTransport _transport;
|
||||
private readonly RpcRouter<TContext> _router;
|
||||
private readonly TContext _context;
|
||||
private static readonly TimeSpan s_dispatcherGrace = TimeSpan.FromSeconds(5);
|
||||
private readonly TimeProvider _time;
|
||||
private readonly RpcSessionOptions _options;
|
||||
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _pending = new();
|
||||
@@ -58,6 +60,7 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
private long _lastReceive;
|
||||
private int _violations;
|
||||
private bool _violated;
|
||||
private string? _transportFailure;
|
||||
|
||||
public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null)
|
||||
{
|
||||
@@ -76,6 +79,9 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
/// <summary>对端上一次发来任何帧到现在的时间。静默死亡(断电、进程被冻结)的对端不会关闭连接,调用方据此设置空闲超时。</summary>
|
||||
public TimeSpan IdleFor => _time.GetElapsedTime(Volatile.Read(ref _lastReceive));
|
||||
|
||||
/// <summary>The first transport send failure observed by this session, if any.</summary>
|
||||
public string? TransportFailure => Volatile.Read(ref _transportFailure);
|
||||
|
||||
/// <summary>读帧并分发,直到连接关闭或被取消。</summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -130,14 +136,8 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
}
|
||||
|
||||
await linked.CancelAsync();
|
||||
try
|
||||
{
|
||||
await dispatcher;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常结束
|
||||
}
|
||||
// 某个入站处理器卡住了也不能拖住整个会话的结束(链路据此重连):它随取消令牌自己收尾
|
||||
await Bounded.WaitAsync(dispatcher, s_dispatcherGrace, _time);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,11 +197,11 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
_router.CheckSend(message.Method, message.Id is null ? MethodKind.Notification : MethodKind.Request);
|
||||
}
|
||||
|
||||
return _transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
|
||||
return SendTransportAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
|
||||
}
|
||||
|
||||
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) =>
|
||||
_transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct);
|
||||
SendTransportAsync(new RpcFrame(FrameKind.Binary, frame), ct);
|
||||
|
||||
public ValueTask CloseAsync(string reason, CancellationToken ct) => _transport.CloseAsync(reason, ct);
|
||||
|
||||
@@ -239,6 +239,30 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask SendTransportAsync(RpcFrame frame, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _transport.SendAsync(frame, ct);
|
||||
}
|
||||
catch (Exception ex) when (!ct.IsCancellationRequested && ex is WebSocketException or IOException or TimeoutException or ObjectDisposedException)
|
||||
{
|
||||
Interlocked.CompareExchange(ref _transportFailure, ex.Message, null);
|
||||
try
|
||||
{
|
||||
// A failed send must also stop the receive loop. Otherwise callers that log and swallow this
|
||||
// exception can leave the session marked connected forever on an already-aborted socket.
|
||||
_stop.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Disposal raced with the failed send.
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static RpcMessage? Parse(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -5,7 +5,7 @@ public sealed class FleetServerOptions
|
||||
/// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary>
|
||||
public string NodeIdPrefix { get; set; } = "node";
|
||||
|
||||
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>probe</c> → <c>prb</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
|
||||
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>edge</c> → <c>edg</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
|
||||
public Dictionary<string, string> NodeIdPrefixes { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public int MaxFrameBytes { get; set; } = 16 << 20;
|
||||
@@ -16,6 +16,18 @@ public sealed class FleetServerOptions
|
||||
|
||||
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>处理节点的一条消息(心跳、hello、收件箱)最多用这么久:钩子卡在数据库或外部查询里时,不能让这条连接停在「在线」而收不到任何心跳超时。超过就断开,节点按退避重连。</summary>
|
||||
public TimeSpan HandlerTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>节点违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开这条连接。</summary>
|
||||
public int MaxViolations { get; set; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>握手时的版本要求:服务端的版本(告诉节点),以及节点至少要是哪个版本。每次握手都重新取,所以改配置不用重启。</summary>
|
||||
public interface IFleetVersionPolicy
|
||||
{
|
||||
string? ServerVersion { get; }
|
||||
|
||||
/// <summary>空表示不要求。</summary>
|
||||
string? MinNodeVersion { get; }
|
||||
}
|
||||
@@ -6,6 +6,17 @@ namespace Fleet.Server;
|
||||
|
||||
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
||||
|
||||
/// <summary>
|
||||
/// 节点的真实来源地址:前面有反向代理时,套接字上只有代理的地址,产品在这里决定信不信转发头。
|
||||
/// 没有注册就用套接字地址。
|
||||
/// </summary>
|
||||
public interface IClientAddressSource
|
||||
{
|
||||
/// <param name="socket">套接字上看到的对端地址。</param>
|
||||
/// <param name="header">按名字取请求头的所有值。</param>
|
||||
string? Resolve(System.Net.IPAddress? socket, Func<string, IEnumerable<string>> header);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
|
||||
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
|
||||
@@ -19,21 +30,6 @@ public interface IServerIdentity
|
||||
}
|
||||
|
||||
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
|
||||
/// <summary>节点握手时自述的二进制(旧节点没有)。</summary>
|
||||
public sealed record BinaryEvidence(string? Sha256, string? Manifest, string? Signature);
|
||||
|
||||
/// <summary>是否让这个节点继续:不放行时 <c>Reason</c> 会原样告诉节点(这是它自己的信息,不是秘密)。</summary>
|
||||
public sealed record BinaryDecision(bool Accept, string Reason = "");
|
||||
|
||||
/// <summary>
|
||||
/// 服务端对节点二进制的把关:节点第一次握手成功的那一刻绑定它的二进制哈希,之后哈希对不上、又不在 CI 签名的更新清单里,就不让它继续运行。
|
||||
/// 没有注册实现时不做任何检查。
|
||||
/// </summary>
|
||||
public interface IBinaryAttestor
|
||||
{
|
||||
ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IOperatorTrustSource
|
||||
{
|
||||
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
|
||||
@@ -53,7 +49,8 @@ public enum NodeEventKind
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null);
|
||||
/// <param name="RemoteIp">这条连接的来源地址(经过 <see cref="IClientAddressSource"/>);没有就是 null。</param>
|
||||
public sealed record NodeEvent(NodeEventKind Kind, NodeRecord Node, long AtMs, HelloParams? Hello = null, JsonElement? Extra = null, string? Reason = null, string? RemoteIp = null);
|
||||
|
||||
public interface INodeEventSink
|
||||
{
|
||||
|
||||
@@ -73,7 +73,7 @@ public static class FleetEnrollmentEndpoints
|
||||
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "request body is not valid");
|
||||
}
|
||||
|
||||
Result<EnrollResponse> result = await service.EnrollAsync(request, http.Connection.RemoteIpAddress?.ToString(), http.RequestAborted);
|
||||
Result<EnrollResponse> result = await service.EnrollAsync(request, ClientIp(http), http.RequestAborted);
|
||||
if (result.TryGet(out EnrollResponse? response, out FleetError? error))
|
||||
{
|
||||
return Results.Json(response, EnrollmentJsonContext.Default.EnrollResponse, statusCode: StatusCodes.Status201Created);
|
||||
@@ -88,6 +88,11 @@ public static class FleetEnrollmentEndpoints
|
||||
return Problem(status, error.Code, error.Message);
|
||||
}
|
||||
|
||||
private static string? ClientIp(HttpContext http) =>
|
||||
http.RequestServices.GetService<IClientAddressSource>() is { } source
|
||||
? source.Resolve(http.Connection.RemoteIpAddress, name => http.Request.Headers[name].Select(v => v ?? ""))
|
||||
: http.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
private static async Task<EnrollRequest?> ReadBodyAsync(HttpContext http, CancellationToken ct)
|
||||
{
|
||||
if (http.Request.ContentLength > MaxBodyBytes)
|
||||
|
||||
@@ -25,10 +25,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
IEnumerable<INodeEventSink> sinks,
|
||||
FleetRouter router,
|
||||
IServerIdentity? serverIdentity = null,
|
||||
IBinaryAttestor? binaryAttestor = null)
|
||||
IFleetVersionPolicy? versionPolicy = null)
|
||||
{
|
||||
VersionPolicy = versionPolicy;
|
||||
ServerIdentity = serverIdentity;
|
||||
BinaryAttestor = binaryAttestor;
|
||||
Store = store;
|
||||
Options = options;
|
||||
Time = time;
|
||||
@@ -45,8 +45,7 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
|
||||
internal IServerIdentity? ServerIdentity { get; }
|
||||
|
||||
/// <summary>节点二进制的把关;没有时不检查。</summary>
|
||||
internal IBinaryAttestor? BinaryAttestor { get; }
|
||||
internal IFleetVersionPolicy? VersionPolicy { get; }
|
||||
|
||||
internal FleetServerOptions Options { get; }
|
||||
|
||||
@@ -72,9 +71,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
_connections.TryGetValue(node, out NodeConnection? c) ? c.Session : null;
|
||||
|
||||
/// <summary>处理一条 WebSocket 连接,直到连接结束。</summary>
|
||||
public async Task RunConnectionAsync(WebSocket socket, CancellationToken ct)
|
||||
public async Task RunConnectionAsync(WebSocket socket, string? remoteIp, CancellationToken ct)
|
||||
{
|
||||
using var connection = new NodeConnection(this, socket);
|
||||
await using var connection = new NodeConnection(this, socket, remoteIp);
|
||||
await connection.RunAsync(ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ internal static partial class FleetLog
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "node disconnected node={Node} reason={Reason}")]
|
||||
public static partial void Disconnected(ILogger logger, string node, string reason);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "node below the required version node={Node} version={Version} required={Required}")]
|
||||
public static partial void VersionTooOld(ILogger logger, string node, string version, string required);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "node auth rejected err={Err}")]
|
||||
public static partial void AuthRejected(ILogger logger, string err);
|
||||
|
||||
|
||||
@@ -29,10 +29,10 @@ public sealed class FleetServerBuilder
|
||||
internal Func<NodeRecord, string?>? RoleOf => _roleOf;
|
||||
|
||||
/// <summary>
|
||||
/// 方法声明表(框架的加产品的,见 <see cref="MethodTable.Combine"/>)和节点的角色解析(例如按节点 ID 前缀区分 Agent 和 Probe)。必须设置:
|
||||
/// 方法声明表(框架的加产品的,见 <see cref="MethodTable.Combine"/>)和可选的节点角色解析(声明表里限定了角色的方法才需要)。方法表必须设置:
|
||||
/// 路由器据此核对每个处理函数的方向、类型和角色,并拒绝没有声明或不该由节点发起的方法。
|
||||
/// </summary>
|
||||
public FleetServerBuilder WithMethods(MethodTable methods, Func<NodeRecord, string?> roleOf)
|
||||
public FleetServerBuilder WithMethods(MethodTable methods, Func<NodeRecord, string?>? roleOf = null)
|
||||
{
|
||||
_methods = methods;
|
||||
_roleOf = roleOf;
|
||||
@@ -111,7 +111,7 @@ public static class FleetServerExtensions
|
||||
provider.GetServices<INodeEventSink>(),
|
||||
provider.GetRequiredService<FleetRouter>(),
|
||||
provider.GetService<IServerIdentity>(),
|
||||
provider.GetService<IBinaryAttestor>()));
|
||||
provider.GetService<IFleetVersionPolicy>()));
|
||||
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
return builder;
|
||||
@@ -129,7 +129,10 @@ public static class FleetServerExtensions
|
||||
}
|
||||
|
||||
using System.Net.WebSockets.WebSocket socket = await http.WebSockets.AcceptWebSocketAsync();
|
||||
await http.RequestServices.GetRequiredService<FleetLinkHost>().RunConnectionAsync(socket, http.RequestAborted);
|
||||
string? remote = http.RequestServices.GetService<IClientAddressSource>() is { } source
|
||||
? source.Resolve(http.Connection.RemoteIpAddress, name => http.Request.Headers[name].Select(v => v ?? ""))
|
||||
: http.Connection.RemoteIpAddress?.ToString();
|
||||
await http.RequestServices.GetRequiredService<FleetLinkHost>().RunConnectionAsync(socket, remote, http.RequestAborted);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -88,18 +88,6 @@ internal sealed partial class NodeConnection
|
||||
return false;
|
||||
}
|
||||
|
||||
// 二进制:第一次握手成功的这一刻绑定,之后哈希对不上又不在 CI 签名的更新清单里,就不让它继续运行
|
||||
if (_host.BinaryAttestor is { } attestor)
|
||||
{
|
||||
BinaryDecision decision = await attestor.CheckAsync(node, new BinaryEvidence(auth!.Binary, auth.Release, auth.ReleaseSignature), ct);
|
||||
if (!decision.Accept)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.BinaryMismatch, decision.Reason)), ct);
|
||||
await RejectAsync(null, "binary mismatch: " + decision.Reason, ct);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
|
||||
await SendAsync(
|
||||
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
|
||||
|
||||
@@ -34,12 +34,20 @@ internal sealed partial class NodeConnection
|
||||
{
|
||||
if (TryParse(frame.Data, out RpcMessage? message))
|
||||
{
|
||||
await HandleMessageAsync(message, ct);
|
||||
if (!await Bounded.WaitAsync(HandleMessageAsync(message, ct), _host.Options.HandlerTimeout, _host.Time))
|
||||
{
|
||||
_closeReason ??= "handler timeout";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleBinaryAsync(frame.Data, ct);
|
||||
if (!await Bounded.WaitAsync(HandleBinaryAsync(frame.Data, ct), _host.Options.HandlerTimeout, _host.Time))
|
||||
{
|
||||
_closeReason ??= "handler timeout";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +72,17 @@ internal sealed partial class NodeConnection
|
||||
case FleetMethods.Heartbeat:
|
||||
await HandleHeartbeatAsync(message, ct);
|
||||
return;
|
||||
case FleetMethods.Stopping:
|
||||
try
|
||||
{
|
||||
string? reason = message.Params?.Deserialize(FleetRpcContext.Default.StoppingParams)?.Reason;
|
||||
_closeReason = reason is "updating" ? "updating" : "stopping";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_closeReason = "stopping";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_node!.Status != NodeStatus.Active)
|
||||
@@ -132,6 +151,20 @@ internal sealed partial class NodeConnection
|
||||
return;
|
||||
}
|
||||
|
||||
string? minVersion = _host.VersionPolicy?.MinNodeVersion;
|
||||
if (NodeVersions.IsBelow(hello.NodeVersion, minVersion))
|
||||
{
|
||||
// 版本不够:不登记在线,告诉节点要求的版本,关闭连接。节点等几秒(它可能正在更新)再握手。
|
||||
FleetLog.VersionTooOld(_host.Logger, _node!.Id.Value, hello.NodeVersion, minVersion!);
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.VersionRequired, $"node version {hello.NodeVersion} is below the required {minVersion}")), ct);
|
||||
}
|
||||
|
||||
await CloseAsync("version required");
|
||||
return;
|
||||
}
|
||||
|
||||
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
_session = _session! with
|
||||
{
|
||||
@@ -150,7 +183,7 @@ internal sealed partial class NodeConnection
|
||||
await SendAsync(
|
||||
RpcMessage.Success(
|
||||
message.Id.Value,
|
||||
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
|
||||
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs, ServerVersion = _host.VersionPolicy?.ServerVersion },
|
||||
FleetRpcContext.Default.HelloResult),
|
||||
ct);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ internal sealed partial class NodeConnection
|
||||
|
||||
try
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lifetime.CancelAfter(s_closeTimeout);
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
|
||||
@@ -40,7 +46,6 @@ internal sealed partial class NodeConnection
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
_lifetime.CancelAfter(s_closeTimeout);
|
||||
}
|
||||
|
||||
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
|
||||
@@ -70,7 +75,7 @@ internal sealed partial class NodeConnection
|
||||
|
||||
try
|
||||
{
|
||||
if (_socket.State != WebSocketState.Open)
|
||||
if (_disposed || _socket.State != WebSocketState.Open)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ internal sealed partial class NodeConnection
|
||||
|
||||
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
|
||||
{
|
||||
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
|
||||
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra, kind == NodeEventKind.Offline ? _closeReason : null, _remoteIp);
|
||||
foreach (INodeEventSink sink in _host.Sinks)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -17,6 +17,7 @@ internal sealed partial class NodeConnection
|
||||
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_closeReason ??= _socket.CloseStatusDescription;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Fleet.Server;
|
||||
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
|
||||
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
|
||||
/// </summary>
|
||||
internal sealed partial class NodeConnection : IDisposable
|
||||
internal sealed partial class NodeConnection : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
|
||||
@@ -31,13 +31,17 @@ internal sealed partial class NodeConnection : IDisposable
|
||||
private NodeRecord? _node;
|
||||
private NodeSessionInfo? _session;
|
||||
private string? _closeReason;
|
||||
private bool _disposed;
|
||||
private bool _helloDone;
|
||||
private int _violations;
|
||||
|
||||
public NodeConnection(FleetLinkHost host, WebSocket socket)
|
||||
private readonly string? _remoteIp;
|
||||
|
||||
public NodeConnection(FleetLinkHost host, WebSocket socket, string? remoteIp = null)
|
||||
{
|
||||
_host = host;
|
||||
_socket = socket;
|
||||
_remoteIp = remoteIp;
|
||||
}
|
||||
|
||||
public NodeId NodeId => _node!.Id;
|
||||
@@ -134,10 +138,19 @@ internal sealed partial class NodeConnection : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cipher?.Dispose();
|
||||
_lifetime.Dispose();
|
||||
_sendLock.Dispose();
|
||||
await _sendLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
_disposed = true;
|
||||
_cipher?.Dispose();
|
||||
_lifetime.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// API callers may already be waiting to send; let them observe _disposed.
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class BoundedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WaitAsync_ReturnsFalse_WhenTheWorkNeverFinishes()
|
||||
{
|
||||
var never = new TaskCompletionSource();
|
||||
Assert.False(await Bounded.WaitAsync(never.Task, TimeSpan.FromMilliseconds(100), TimeProvider.System));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitAsync_ReturnsTrue_WhenTheWorkFinishes_OrFailsWithSessionNoise()
|
||||
{
|
||||
Assert.True(await Bounded.WaitAsync(Task.CompletedTask, TimeSpan.FromSeconds(5), TimeProvider.System));
|
||||
Assert.True(await Bounded.WaitAsync(Task.FromException(new IOException("closed")), TimeSpan.FromSeconds(5), TimeProvider.System));
|
||||
Assert.True(await Bounded.WaitAsync(Task.FromException(new ObjectDisposedException("x")), TimeSpan.FromSeconds(5), TimeProvider.System));
|
||||
Assert.True(await Bounded.WaitAsync(Task.FromCanceled(new CancellationToken(true)), TimeSpan.FromSeconds(5), TimeProvider.System));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitAsync_DoesNotHideRealFailures()
|
||||
{
|
||||
await Assert.ThrowsAsync<FormatException>(() => Bounded.WaitAsync(Task.FromException(new FormatException()), TimeSpan.FromSeconds(5), TimeProvider.System));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class BufferedNodeLogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Summary_CountsByLevelAndTypeWithoutText_AndResetsAfterEachTake()
|
||||
{
|
||||
var log = new BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
log.Warn("peer control handshake failed", ("attempt", i));
|
||||
}
|
||||
|
||||
log.Info("link up");
|
||||
log.Error("MsQuic QUIC transport shutdown");
|
||||
|
||||
NodeLogSummary first = log.TakeSummary();
|
||||
Assert.Equal((1L, 5L, 1L), (first.Info, first.Warn, first.Error));
|
||||
Assert.Equal(7, first.LastId);
|
||||
NodeLogTypeCount top = first.Types[0];
|
||||
Assert.Equal((BufferedNodeLog.TypeOf("peer control handshake failed"), "warn", 5L), (top.Type, top.Level, top.Count));
|
||||
|
||||
NodeLogSummary second = log.TakeSummary();
|
||||
Assert.Equal((0L, 0L, 0L), (second.Info, second.Warn, second.Error));
|
||||
Assert.Empty(second.Types);
|
||||
Assert.Equal(7, second.LastId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_ReturnsLinesAfterAnIdAtOrAboveALevel_OldestFirst_AndTheRingKeepsTheNewest()
|
||||
{
|
||||
var log = new BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
|
||||
log.Info("one");
|
||||
log.Warn("two");
|
||||
log.Error("three");
|
||||
log.Info("four");
|
||||
|
||||
Assert.Equal(["two", "three"], log.Read(1, 2, null).Items.Select(e => e.Record.Message));
|
||||
Assert.Equal(["three"], log.Read(0, 10, "error").Items.Select(e => e.Record.Message));
|
||||
Assert.Equal(["two", "three"], log.Read(0, 10, "warn").Items.Select(e => e.Record.Message));
|
||||
|
||||
for (int i = 0; i < 3000; i++)
|
||||
{
|
||||
log.Info("filler", ("n", i));
|
||||
}
|
||||
|
||||
NodeLogPage page = log.Read(0, 200, null);
|
||||
Assert.Equal(200, page.Items.Count);
|
||||
Assert.True(page.Items[0].Id > 4); // 最早的几条已经被环挤掉
|
||||
Assert.Equal(3004, page.LastId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SensitiveFieldsAreRedacted()
|
||||
{
|
||||
var log = new BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
|
||||
log.Info("auth", ("token", "abc"), ("agent", "agt_x"));
|
||||
NodeLogEntry entry = Assert.Single(log.Read(0, 10, null).Items);
|
||||
Assert.Equal("[redacted]", entry.Record.Fields["token"]);
|
||||
Assert.Equal("agt_x", entry.Record.Fields["agent"]);
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,14 @@ public sealed class KnockTests
|
||||
[Fact]
|
||||
public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
using NodeIdentity agent = NodeIdentity.Generate();
|
||||
using NodeIdentity other = NodeIdentity.Generate();
|
||||
byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki);
|
||||
byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki);
|
||||
Assert.Equal(fromProbe, fromAgent);
|
||||
Assert.Equal(32, fromProbe.Length);
|
||||
Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki));
|
||||
byte[] fromDialer = Knock.DeriveKey(dialer, agent.PublicKeySpki);
|
||||
byte[] fromAgent = Knock.DeriveKey(agent, dialer.PublicKeySpki);
|
||||
Assert.Equal(fromDialer, fromAgent);
|
||||
Assert.Equal(32, fromDialer.Length);
|
||||
Assert.NotEqual(fromDialer, Knock.DeriveKey(other, agent.PublicKeySpki));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -61,36 +61,36 @@ public sealed class KnockTests
|
||||
public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets()
|
||||
{
|
||||
using NodeIdentity agent = NodeIdentity.Generate();
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
using NodeIdentity stranger = NodeIdentity.Generate();
|
||||
var roster = new List<byte[]> { probe.PublicKeySpki };
|
||||
var roster = new List<byte[]> { dialer.PublicKeySpki };
|
||||
var gate = new KnockGate(agent, () => roster, new Clock());
|
||||
long now = 1_700_000_000_000;
|
||||
|
||||
byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now);
|
||||
byte[] packet = Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now);
|
||||
Assert.True(gate.Accept(packet, now));
|
||||
Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice");
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster");
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
|
||||
|
||||
// 名册变了:新成员马上能进,被移除的马上进不来
|
||||
using NodeIdentity second = NodeIdentity.Generate();
|
||||
roster.Add(second.PublicKeySpki);
|
||||
Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now));
|
||||
roster.RemoveAt(0);
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now));
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now), now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes()
|
||||
{
|
||||
using NodeIdentity agent = NodeIdentity.Generate();
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
var clock = new Clock();
|
||||
var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3);
|
||||
var gate = new KnockGate(agent, () => [dialer.PublicKeySpki], clock, attemptsPerPeriod: 3);
|
||||
IPAddress noisy = IPAddress.Parse("203.0.113.9");
|
||||
IPAddress quiet = IPAddress.Parse("203.0.113.10");
|
||||
byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki);
|
||||
byte[] key = Knock.DeriveKey(dialer, agent.PublicKeySpki);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Link;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>面板那头只接住连接、什么都不回(线路把握手吞了、代理卡住):节点要在限定时间内放弃这次连接重试,而不是一直等着。</summary>
|
||||
public sealed class LinkConnectTimeoutTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ASilentServer_IsGivenUp_AfterTheConnectTimeout_AndRetried()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var accepted = new List<TcpClient>();
|
||||
using var acceptLoop = new CancellationTokenSource();
|
||||
Task accepting = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
accepted.Add(await listener.AcceptTcpClientAsync(acceptLoop.Token));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 测试结束
|
||||
}
|
||||
});
|
||||
|
||||
var options = new FleetLinkOptions
|
||||
{
|
||||
Server = new Uri($"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"),
|
||||
Identity = NodeIdentity.Generate(),
|
||||
NodeId = new NodeId("dlr_test"),
|
||||
ConnectTimeout = TimeSpan.FromMilliseconds(500),
|
||||
Backoff = new Backoff(TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50)),
|
||||
};
|
||||
var router = new RpcRouter<LinkContext>(RpcSide.Node, FleetMethodTable.Table);
|
||||
var link = new FleetLink(options, router, [], new Outbox(1 << 16), TimeProvider.System, new SilentLog());
|
||||
var reasons = new List<string>();
|
||||
link.OnDisconnected = reason =>
|
||||
{
|
||||
lock (reasons)
|
||||
{
|
||||
reasons.Add(reason);
|
||||
}
|
||||
};
|
||||
|
||||
using var stop = new CancellationTokenSource();
|
||||
Task running = link.RunAsync(stop.Token);
|
||||
try
|
||||
{
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (true)
|
||||
{
|
||||
lock (reasons)
|
||||
{
|
||||
if (reasons.Count >= 2)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(50, deadline.Token);
|
||||
}
|
||||
|
||||
lock (reasons)
|
||||
{
|
||||
Assert.All(reasons, reason => Assert.Contains("timed out", reason, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
Assert.True(accepted.Count >= 2, "the second attempt connects again");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await stop.CancelAsync();
|
||||
try
|
||||
{
|
||||
await running;
|
||||
}
|
||||
catch (OperationCanceledException) when (stop.IsCancellationRequested)
|
||||
{
|
||||
// Canceling the retry delay ends RunAsync with the requested cancellation.
|
||||
}
|
||||
|
||||
await acceptLoop.CancelAsync();
|
||||
await accepting;
|
||||
foreach (TcpClient client in accepted)
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SilentLog : INodeLog
|
||||
{
|
||||
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,27 @@ public sealed class LinkTests
|
||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_RunEnds_EvenWhenAnInboundHandlerIsStuck()
|
||||
{
|
||||
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
||||
MethodTable table = TestTables.Of(TestTables.Req("right.stuck"));
|
||||
var stuck = new TaskCompletionSource<Result<Number>>(TaskCreationOptions.RunContinuationsAsynchronously); // 不理会取消令牌:模拟卡在原生层的处理器
|
||||
var rightRouter = new RpcRouter<string>(RpcSide.Listener, table).On("right.stuck", LinkJson.Default.Number, LinkJson.Default.Number, (_, _, _) => new ValueTask<Result<Number>>(stuck.Task));
|
||||
await using var leftSession = new RpcSession<string>(left, new RpcRouter<string>(RpcSide.Dialer, table), "left", TimeProvider.System);
|
||||
await using var rightSession = new RpcSession<string>(right, rightRouter, "right", TimeProvider.System);
|
||||
Task leftRun = leftSession.RunAsync(default);
|
||||
Task rightRun = rightSession.RunAsync(default);
|
||||
|
||||
_ = leftSession.RequestAsync("right.stuck", new Number { Value = 1 }, LinkJson.Default.Number, LinkJson.Default.Number, default, TimeSpan.FromSeconds(30)).AsTask();
|
||||
await Task.Delay(300); // 请求到达并进入处理器
|
||||
await leftSession.CloseAsync("bye", default);
|
||||
|
||||
await rightRun.WaitAsync(TimeSpan.FromSeconds(10)); // 以前这里会一直等到处理器返回
|
||||
stuck.TrySetResult(Result<Number>.Ok(new Number { Value = 0 }));
|
||||
await leftRun.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// ───────────────────────── 点对点握手 ─────────────────────────
|
||||
|
||||
private sealed class PeerFixture : IAsyncDisposable
|
||||
@@ -282,7 +303,7 @@ public sealed class LinkTests
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private readonly NodeIdentity _identity;
|
||||
|
||||
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
|
||||
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null, int maxSessions = 64)
|
||||
{
|
||||
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
|
||||
_identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey());
|
||||
@@ -293,6 +314,7 @@ public sealed class LinkTests
|
||||
Certificate = Certificate,
|
||||
LookupPeerKey = lookup,
|
||||
Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System),
|
||||
MaxSessions = maxSessions,
|
||||
},
|
||||
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
|
||||
NullNodeLog.Instance, TimeProvider.System);
|
||||
@@ -307,10 +329,10 @@ public sealed class LinkTests
|
||||
|
||||
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
|
||||
|
||||
/// <summary>Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。</summary>
|
||||
/// <summary>Agent 的 TLS 证书公钥:拨号方 用它固定证书、派生敲门密钥。</summary>
|
||||
public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||
|
||||
/// <summary>名册:能敲开门的 Probe 公钥。</summary>
|
||||
/// <summary>名册:能敲开门的 拨号方 公钥。</summary>
|
||||
public List<byte[]> KnockPeers { get; } = [];
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
@@ -331,81 +353,90 @@ public sealed class LinkTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_AuthenticatesTheProbe_AndBothSidesDeriveTheSameDataPlaneCredentials()
|
||||
public async Task PeerHandshake_AuthenticatesTheDialer_AndBothSidesDeriveTheSameDataPlaneCredentials()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
var seen = new TaskCompletionSource<PeerSessionInfo>();
|
||||
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null, (info, transport, ct) =>
|
||||
await using var agent = new PeerFixture(id => id == "dlr_1" ? dialer.PublicKeySpki : null, (info, transport, ct) =>
|
||||
{
|
||||
seen.TrySetResult(info);
|
||||
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
|
||||
});
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
var logs = new Fleet.Hosting.BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
|
||||
var scoped = new ContextNodeLog(logs, ("attempt_id", "test-attempt"));
|
||||
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, scoped, default);
|
||||
await using (transport)
|
||||
{
|
||||
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("prb_1", server.PeerId);
|
||||
Assert.Equal("dlr_1", server.PeerId);
|
||||
Assert.Equal(client.Credentials.Key, server.Credentials.Key);
|
||||
Assert.Equal(client.Credentials.SessionId, server.Credentials.SessionId);
|
||||
NodeLogPage page = logs.Read(0, 32, null);
|
||||
Assert.Equal(new[] { "tcp_connect", "tls", "peer_auth" }, page.Items.Select(x => x.Record.Fields["phase"]));
|
||||
Assert.All(page.Items, entry => Assert.Equal("test-attempt", entry.Record.Fields["attempt_id"]));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_RejectsUnknownProbes_AndForgedSignatures_WithTheSameError()
|
||||
public async Task PeerHandshake_RejectsUnknownDialers_AndForgedSignatures_WithTheSameError()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
using NodeIdentity impostor = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
|
||||
agent.KnockPeers.AddRange([probe.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
|
||||
await using var agent = new PeerFixture(id => id == "dlr_1" ? dialer.PublicKeySpki : null);
|
||||
agent.KnockPeers.AddRange([dialer.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
var logs = new Fleet.Hosting.BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
|
||||
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_unknown", null, 1 << 20, TimeProvider.System, logs, default));
|
||||
NodeLogRecord failure = Assert.Single(logs.Read(0, 32, null).Items, entry => entry.Record.Message == "peer control handshake failed").Record;
|
||||
Assert.Equal("peer_auth", failure.Fields["phase"]);
|
||||
Assert.Equal("InvalidOperationException", failure.Fields["exception"]);
|
||||
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "dlr_1", null, 1 << 20, TimeProvider.System, default));
|
||||
Assert.Equal(unknown.Message, forged.Message);
|
||||
Assert.Contains("fleet_unauthorized", forged.Message);
|
||||
|
||||
// 正主仍然可以连接
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_PinsTheAgentCertificate_ByItsSpkiFingerprint()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到)
|
||||
using NodeIdentity another = NodeIdentity.Generate();
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
MethodTable table = TestTables.Of(TestTables.Req("agent.echo"));
|
||||
var router = new RpcRouter<string>(RpcSide.Listener, table).On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
|
||||
await using var agent = new PeerFixture(
|
||||
_ => probe.PublicKeySpki,
|
||||
_ => dialer.PublicKeySpki,
|
||||
async (info, transport, ct) =>
|
||||
{
|
||||
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
|
||||
await session.RunAsync(ct);
|
||||
});
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System);
|
||||
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "dialer", TimeProvider.System);
|
||||
Task run = client.RunAsync(default);
|
||||
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
|
||||
Assert.Equal(42, result.Value.Value);
|
||||
@@ -416,9 +447,9 @@ public sealed class LinkTests
|
||||
[Fact]
|
||||
public async Task PeerListener_LimitsConcurrentSessions_AndSurvivesGarbageConnections()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 不说 TLS 的连接:握手失败,监听继续
|
||||
@@ -428,16 +459,75 @@ public sealed class LinkTests
|
||||
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
|
||||
}
|
||||
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>名额只数通过敲门的会话:一堆还没敲门的连接占着,名册里的拨号方照样连得上(之前它们共用一个上限,节点多了之后后到的拨号方被永远拒绝)。</summary>
|
||||
[Fact]
|
||||
public async Task PeerListener_SessionLimitCountsOnlyAdmittedPeers_SoSilentConnectionsCannotLockOutTheRoster()
|
||||
{
|
||||
using NodeIdentity first = NodeIdentity.Generate();
|
||||
using NodeIdentity second = NodeIdentity.Generate();
|
||||
using NodeIdentity third = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(id => id == "dlr_1" ? first.PublicKeySpki : id == "dlr_2" ? second.PublicKeySpki : third.PublicKeySpki, maxSessions: 2);
|
||||
agent.KnockPeers.AddRange([first.PublicKeySpki, second.PublicKeySpki, third.PublicKeySpki]);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 20 个什么都不说的连接:占着待敲门的名额,但不占会话名额
|
||||
var silent = new List<TcpClient>();
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
|
||||
silent.Add(client);
|
||||
}
|
||||
|
||||
(_, TlsFrameTransport a) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, first, "dlr_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(_, TlsFrameTransport b) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, second, "dlr_2", null, 1 << 20, TimeProvider.System, default);
|
||||
|
||||
// 会话名额(2)满了:第三个在名册里的拨号方被拒绝,一个会话结束之后又能连上
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
{
|
||||
(_, TlsFrameTransport refused) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, third, "dlr_3", null, 1 << 20, TimeProvider.System, default);
|
||||
await refused.DisposeAsync();
|
||||
});
|
||||
await a.DisposeAsync();
|
||||
bool reconnected = false;
|
||||
for (int attempt = 0; attempt < 50 && !reconnected; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
(_, TlsFrameTransport again) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, third, "dlr_3", null, 1 << 20, TimeProvider.System, default);
|
||||
await again.DisposeAsync();
|
||||
reconnected = true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or InvalidOperationException)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(reconnected);
|
||||
await b.DisposeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (TcpClient client in silent)
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭)
|
||||
@@ -465,8 +555,8 @@ public sealed class LinkTests
|
||||
Assert.Equal(0, read);
|
||||
}
|
||||
|
||||
// 名册里的 Probe 仍然进得来
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
// 名册里的 拨号方 仍然进得来
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,10 @@ public sealed class MethodDirectionTests
|
||||
// t.ping 声明成请求:不能按通知注册
|
||||
Assert.Throws<InvalidOperationException>(() => server.OnNotify("t.ping", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
|
||||
|
||||
// 节点这一侧:t.note 只给 agent 角色,probe 注册不了;t.push 是它该处理的
|
||||
var probe = new RpcRouter<string>(RpcSide.Node, s_table, "probe");
|
||||
probe.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
|
||||
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "probe").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
|
||||
// 节点这一侧:t.note 只给 agent 角色,dialer 注册不了;t.push 是它该处理的
|
||||
var dialer = new RpcRouter<string>(RpcSide.Node, s_table, "dialer");
|
||||
dialer.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
|
||||
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "dialer").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
|
||||
|
||||
server.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
|
||||
}
|
||||
@@ -99,7 +99,7 @@ public sealed class MethodDirectionTests
|
||||
{
|
||||
RpcRouter<string> server = Server().OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask);
|
||||
Assert.Null((await server.DispatchCheckedAsync("agent", Notification("t.note"), default)).Violation);
|
||||
Assert.NotNull((await server.DispatchCheckedAsync("probe", Notification("t.note"), default)).Violation);
|
||||
Assert.NotNull((await server.DispatchCheckedAsync("dialer", Notification("t.note"), default)).Violation);
|
||||
Assert.NotNull((await server.DispatchCheckedAsync(null!, Notification("t.note"), default)).Violation);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ public sealed class MethodDirectionTests
|
||||
// 对端的角色也要允许
|
||||
var node = new RpcRouter<string>(RpcSide.Node, s_table, "agent");
|
||||
node.CheckSend("t.note", MethodKind.Notification, "agent");
|
||||
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "probe"));
|
||||
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "dialer"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -126,7 +126,7 @@ public sealed class MethodDirectionTests
|
||||
|
||||
// 服务端要处理 t.ping 和 t.note;t.inline 在握手阶段直接处理,不算缺
|
||||
Assert.Equal(["t.note", "t.ping"], s_table.MissingHandlers(RpcSide.Server, null, []));
|
||||
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "probe", []));
|
||||
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "dialer", []));
|
||||
Assert.Equal(["t.note"], s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping"]));
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping", "t.note"]));
|
||||
// 同名方法在对等面的接收方是拨号方?不:t.ping 在对等面由监听方发起,拨号方处理
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class NodeVersionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("0.4.2-dev.73-5245f03", "0.4.2-dev.74", true)]
|
||||
[InlineData("0.4.2-dev.73-5245f03", "0.4.2-dev.73-abcdef0", false)] // 哈希不参与比较
|
||||
[InlineData("0.4.2-dev.100-aaaaaaa", "0.4.2-dev.99", false)] // 序号按数字比,不是按字符串
|
||||
[InlineData("0.4.2-dev.999", "0.4.2", true)] // 正式版高于预发布版
|
||||
[InlineData("0.4.2", "0.4.2-dev.5", false)]
|
||||
[InlineData("0.4.1", "0.4.2-dev.1", true)]
|
||||
[InlineData("v0.5.0", "0.4.9", false)]
|
||||
[InlineData("0.4.2-dev.73-5245f03", "", false)] // 没有要求
|
||||
[InlineData("", "0.4.2", false)] // 认不出来的版本号不拒绝
|
||||
[InlineData("not-a-version", "0.4.2", false)]
|
||||
[InlineData("0.4.2", "garbage", false)]
|
||||
public void IsBelow_ComparesCoreThenReleaseThenTheDevCounter(string actual, string required, bool expected) =>
|
||||
Assert.Equal(expected, NodeVersions.IsBelow(actual, required));
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
|
||||
/// <summary>节点自己要求有属主(passkey):拨号方和监听方共用这一份,没有属主就不运行。</summary>
|
||||
public sealed class OwnerGuardTests : IDisposable
|
||||
{
|
||||
private readonly TestAuthenticator _alice = new();
|
||||
|
||||
@@ -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/moshiro [--meta '{"probe_tcp_port":7443,...}'] [--version 1.0]
|
||||
// fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/moshiro [--meta '{"tcp_port":7443,...}'] [--version 1.0]
|
||||
// 一次性运行:生成身份、注册、写入身份目录,然后退出。
|
||||
string? Arg(string name) => args.SkipWhile(a => a != name).Skip(1).FirstOrDefault();
|
||||
|
||||
|
||||
@@ -33,4 +33,6 @@
|
||||
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
|
||||
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
|
||||
Condition="'$(UseAotAnywhere)' == 'true'" />
|
||||
<Import Project="..\..\..\shared\MoShiro.Measurement\StaticMsQuic.targets"
|
||||
Condition="'$(StaticMsQuic)' == 'true'" />
|
||||
</Project>
|
||||
+1093
-725
File diff suppressed because it is too large.
Load diff
@@ -1,21 +0,0 @@
|
||||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
<PropertyGroup>
|
||||
<OptimizationPreference>Size</OptimizationPreference>
|
||||
<UseSystemResourceKeys>true</UseSystemResourceKeys>
|
||||
<StackTraceSupport>false</StackTraceSupport>
|
||||
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
|
||||
<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>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../BannedSymbols.Node.txt" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,59 +0,0 @@
|
||||
# MoShiro Probe
|
||||
|
||||
Probe 是网络测量的发起端,通常运行在家宽或其他需要观测的网络出口。它连接 Backend(身份、Agent 列表、状态)和 Agent(测量),执行线路任务,并把本端采集的原始数据交给 Agent 汇总上报。Probe 不提供远程命令或文件管理功能。
|
||||
|
||||
## 工作流程
|
||||
|
||||
```text
|
||||
Backend ──任务──> Agent <──测量会话──> Probe
|
||||
│ ▲ │ │
|
||||
│ └──汇总并上报数据 <───┘ │
|
||||
└─────Agent 列表、状态(Fleet 长连接)───────────┘
|
||||
```
|
||||
|
||||
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
|
||||
|
||||
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
|
||||
|
||||
## 构建
|
||||
|
||||
需要 .NET SDK 10。为应用 根目录的 `global.json` 的 SDK 设置,请从 `backend/` 目录构建:
|
||||
|
||||
```sh
|
||||
cd backend
|
||||
dotnet build probe/src/MoShiro.Probe -c Release
|
||||
```
|
||||
|
||||
发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
|
||||
|
||||
```sh
|
||||
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(生成 probe 角色的加入令牌),在目标机上注册(密钥由 Probe 自己生成):
|
||||
|
||||
```sh
|
||||
fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro
|
||||
```
|
||||
|
||||
写 `/etc/moshiro/probe.json`:`{"fleet":{"server":"https://panel.example.com","identity_dir":"/etc/moshiro"}}`(`install.sh probe --server … --join … --name …` 会一并完成)。随后运行:
|
||||
|
||||
```sh
|
||||
moshiro-probe --config /etc/moshiro/probe.json
|
||||
```
|
||||
|
||||
不传 `--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 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,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。首次导入和自动更新见部署文档。
|
||||
@@ -1,422 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Link;
|
||||
using Fleet.Rpc;
|
||||
using MoShiro.Measurement;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
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 下发的 <c>line_task_set</c> 运行角色 →
|
||||
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
|
||||
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
|
||||
/// </summary>
|
||||
public sealed class AgentConnection
|
||||
{
|
||||
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
|
||||
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
|
||||
private static readonly RpcRouter<ConnectionSession> s_router = BuildRouter();
|
||||
|
||||
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
|
||||
public static RpcRouter<ConnectionSession> Router => s_router;
|
||||
|
||||
private readonly ConnectionEnv _env;
|
||||
private volatile ConnectionSession? _session;
|
||||
|
||||
public AgentConnection(KnownAgent endpoint, ConnectionEnv env)
|
||||
{
|
||||
Endpoint = endpoint;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
public KnownAgent Endpoint { get; }
|
||||
|
||||
public bool IsConnected => _session is not null;
|
||||
|
||||
public int RoleCount => _session?.RoleCount ?? 0;
|
||||
|
||||
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(20));
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
string reason = await RunOnceAsync(backoff, ct);
|
||||
_env.Log.Warn("agent session ended", ("agent", Endpoint.AgentId), ("err", reason));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException or InvalidOperationException or System.Security.Authentication.AuthenticationException or OperationCanceledException or FormatException)
|
||||
{
|
||||
_env.Log.Warn("agent connection failed", ("agent", Endpoint.AgentId), ("err", ex.Message));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(backoff.Next(), _env.Time, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
|
||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
|
||||
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
|
||||
var remote = (IPEndPoint)info.Remote!;
|
||||
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
||||
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
|
||||
udp.Bind();
|
||||
var outbox = new Outbox(8 << 20);
|
||||
var clock = new ClockEstimator();
|
||||
var shipper = new FragmentShipper(outbox, new NodeId(Endpoint.AgentId), new ProbeId(_env.ProbeId), clock, _env.Time);
|
||||
var blockEnv = new BlockEnv
|
||||
{
|
||||
Clock = _env.Clock,
|
||||
Log = _env.Log,
|
||||
Session = () => data,
|
||||
Udp = udp,
|
||||
PeerAddress = remote.Address,
|
||||
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
|
||||
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
|
||||
Fragments = shipper,
|
||||
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
|
||||
MaxBulkMbps = _env.Limits.MaxTestMbps,
|
||||
};
|
||||
var session = new ConnectionSession(blockEnv, clock, outbox, _env);
|
||||
var rpc = new RpcSession<ConnectionSession>(transport, s_router, session, _env.Time);
|
||||
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
|
||||
|
||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
Task run = rpc.RunAsync(cts.Token);
|
||||
Task receive = udp.RunAsync(cts.Token);
|
||||
Task pump = PumpAsync(rpc, outbox, cts.Token);
|
||||
try
|
||||
{
|
||||
// 先同步时钟(块头里要带偏移);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));
|
||||
Task maintenance = MaintainAsync(rpc, session, clock, cts.Token);
|
||||
Task finished = await Task.WhenAny(run, receive, pump, maintenance);
|
||||
await cts.CancelAsync();
|
||||
await Task.WhenAll(Quiet(run), Quiet(receive), Quiet(pump), Quiet(maintenance));
|
||||
return finished == run ? "closed by the agent" : "session task failed";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_session = null;
|
||||
await cts.CancelAsync();
|
||||
await session.StopAllAsync();
|
||||
await Quiet(run);
|
||||
await Quiet(receive);
|
||||
await Quiet(pump);
|
||||
await rpc.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Quiet(Task task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException)
|
||||
{
|
||||
// 会话结束时的正常噪声
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(RpcSession<ConnectionSession> rpc, Outbox outbox, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
byte[] frame = await outbox.TakeNextAsync(2 << 20, ct);
|
||||
await rpc.SendBinaryAsync(frame, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncClockAsync(RpcSession<ConnectionSession> rpc, ClockEstimator clock, int rounds, CancellationToken ct)
|
||||
{
|
||||
for (int i = 0; i < rounds; i++)
|
||||
{
|
||||
long sent = _env.Clock.NowUs();
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>切块(每 10 秒)和时钟同步(每 30 秒)。</summary>
|
||||
private async Task MaintainAsync(RpcSession<ConnectionSession> rpc, ConnectionSession session, ClockEstimator clock, CancellationToken ct)
|
||||
{
|
||||
using var flush = new PeriodicTimer(s_flushInterval, _env.Time);
|
||||
long lastClockUs = _env.Clock.NowUs();
|
||||
while (await flush.WaitForNextTickAsync(ct))
|
||||
{
|
||||
session.FlushLineBlocks();
|
||||
if (_env.Clock.NowUs() - lastClockUs >= s_clockInterval.TotalMicroseconds)
|
||||
{
|
||||
lastClockUs = _env.Clock.NowUs();
|
||||
await SyncClockAsync(rpc, clock, rounds: 1, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RpcRouter<ConnectionSession> BuildRouter() =>
|
||||
new RpcRouter<ConnectionSession>(RpcSide.Dialer, MoShiroMethodTable.Table)
|
||||
.On(
|
||||
FleetMethods.StateSync,
|
||||
FleetRpcContext.Default.StateSyncParams,
|
||||
FleetRpcContext.Default.StateSyncResult,
|
||||
async (session, sync, _) =>
|
||||
{
|
||||
var result = new StateSyncResult { AppliedVersion = sync.Version };
|
||||
if (sync.Type == LineTaskSet.StateType)
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
||||
result.Rejected.AddRange(await session.ApplyAsync(set));
|
||||
}
|
||||
|
||||
return Result<StateSyncResult>.Ok(result);
|
||||
})
|
||||
.On(
|
||||
MoShiroMethods.TestPhase,
|
||||
MoShiroJsonContext.Default.TestPhaseStartParams,
|
||||
MoShiroJsonContext.Default.TestPhaseStartResult,
|
||||
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
|
||||
.On(
|
||||
MoShiroMethods.TestPhaseEnd,
|
||||
MoShiroJsonContext.Default.TestPhaseEndParams,
|
||||
MoShiroJsonContext.Default.TestPhaseEndResult,
|
||||
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
|
||||
.OnNotify(
|
||||
MoShiroMethods.TestAbort,
|
||||
MoShiroJsonContext.Default.TestAbortParams,
|
||||
async (session, abort, _) => await session.AbortAsync(abort.TestId))
|
||||
.OnNotify(
|
||||
FleetMethods.OutboxAck,
|
||||
FleetRpcContext.Default.OutboxAckParams,
|
||||
(session, ack, _) =>
|
||||
{
|
||||
session.Outbox.Acknowledge(ack.Ids.Concat(ack.Duplicates).Select(Guid.Parse));
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。</summary>
|
||||
public sealed class ConnectionSession
|
||||
{
|
||||
private const int MaxBlocks = 16;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, RunningBlock> _lineBlocks = [];
|
||||
private readonly ConcurrentDictionary<uint, PhaseState> _phases = new();
|
||||
|
||||
public ConnectionSession(BlockEnv blockEnv, ClockEstimator clock, Outbox outbox, ConnectionEnv env)
|
||||
{
|
||||
BlockEnv = blockEnv;
|
||||
Clock = clock;
|
||||
Outbox = outbox;
|
||||
Env = env;
|
||||
}
|
||||
|
||||
public BlockEnv BlockEnv { get; }
|
||||
|
||||
public ClockEstimator Clock { get; }
|
||||
|
||||
public Outbox Outbox { get; }
|
||||
|
||||
public ConnectionEnv Env { get; }
|
||||
|
||||
public int RoleCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _lineBlocks.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。</summary>
|
||||
public async Task<List<RejectedItem>> ApplyAsync(LineTaskSet set)
|
||||
{
|
||||
var rejected = new List<RejectedItem>();
|
||||
var wanted = new Dictionary<string, BlockSpec>();
|
||||
foreach (LineTask task in set.Tasks)
|
||||
{
|
||||
BlockSpec? spec = BlockCatalog.ProbeKinds.Contains(task.Kind)
|
||||
? BlockSpec.Create(task.Kind, task.Params, task.StreamId, task.Port, new FragmentScope(task.Id, null, null))
|
||||
: null;
|
||||
if (spec is null)
|
||||
{
|
||||
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = BlockCatalog.ProbeKinds.Contains(task.Kind) ? "invalid_params" : "unsupported" });
|
||||
}
|
||||
else if (wanted.Count >= MaxBlocks)
|
||||
{
|
||||
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = "budget_exceeded" });
|
||||
}
|
||||
else
|
||||
{
|
||||
wanted[task.Id.Value] = spec;
|
||||
}
|
||||
}
|
||||
|
||||
var stopped = new List<RunningBlock>();
|
||||
lock (_gate)
|
||||
{
|
||||
foreach ((string id, RunningBlock running) in _lineBlocks.ToList())
|
||||
{
|
||||
if (!wanted.TryGetValue(id, out BlockSpec? spec) || spec.Signature != running.Signature)
|
||||
{
|
||||
stopped.Add(running);
|
||||
_lineBlocks.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((string id, BlockSpec spec) in wanted)
|
||||
{
|
||||
if (!_lineBlocks.ContainsKey(id))
|
||||
{
|
||||
_lineBlocks[id] = RunningBlock.Start(BlockCatalog.Find(spec.KindId)!.Dialer(BlockEnv, spec), spec.Signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 被停止的积木把已经稳定的行交出去(刚发出、还在等回复的行不交:它们不是丢失,只是没有等到)
|
||||
foreach (RunningBlock running in stopped)
|
||||
{
|
||||
await running.StopAsync();
|
||||
running.Block.Flush(FlushMode.Settled);
|
||||
}
|
||||
|
||||
return rejected;
|
||||
}
|
||||
|
||||
public void FlushLineBlocks()
|
||||
{
|
||||
List<RunningBlock> blocks;
|
||||
lock (_gate)
|
||||
{
|
||||
blocks = [.. _lineBlocks.Values];
|
||||
}
|
||||
|
||||
foreach (RunningBlock running in blocks)
|
||||
{
|
||||
running.Block.Flush(FlushMode.Settled);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。</summary>
|
||||
public bool StartPhase(TestPhaseStartParams start)
|
||||
{
|
||||
string? direction = start.Params.TryGetValue("direction", out System.Text.Json.JsonElement d) ? d.GetString() : null;
|
||||
if (_phases.ContainsKey(start.StreamId) || !BlockCatalog.ProbeKinds.Contains(start.Kind) || BlockCatalog.Find(start.Kind)!.Supports(direction) is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (start.Kind == "udp_train")
|
||||
{
|
||||
double mbps = start.Params["mbps"].GetDouble();
|
||||
long size = start.Params["size"].GetInt64();
|
||||
long pps = (long)(mbps * 1_000_000 / 8 / Math.Max(1, size));
|
||||
if (mbps > Env.Limits.MaxTestMbps || pps > Env.Limits.MaxTotalPps)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
BlockSpec? spec = BlockSpec.Create(start.Kind, start.Params, start.StreamId, start.Port, new FragmentScope(null, start.TestId, start.Phase));
|
||||
if (spec is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_phases[start.StreamId] = new PhaseState(start.TestId, start.Kind, RunningBlock.Start(BlockCatalog.Find(start.Kind)!.Dialer(BlockEnv, spec), spec.Signature));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>阶段结束:收尾、交出列块,返回块数。</summary>
|
||||
public async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
|
||||
{
|
||||
if (!_phases.TryRemove(end.StreamId, out PhaseState? phase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "udp_train" ? 200 : 300), BlockEnv.Time, ct);
|
||||
await phase.Running.StopAsync();
|
||||
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
|
||||
}
|
||||
|
||||
public async Task AbortAsync(TestId test)
|
||||
{
|
||||
foreach ((uint stream, PhaseState phase) in _phases.ToList())
|
||||
{
|
||||
if (phase.Test == test && _phases.TryRemove(stream, out _))
|
||||
{
|
||||
await phase.Running.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAllAsync()
|
||||
{
|
||||
List<RunningBlock> blocks;
|
||||
lock (_gate)
|
||||
{
|
||||
blocks = [.. _lineBlocks.Values];
|
||||
_lineBlocks.Clear();
|
||||
}
|
||||
|
||||
blocks.AddRange(_phases.Values.Select(p => p.Running));
|
||||
_phases.Clear();
|
||||
foreach (RunningBlock running in blocks)
|
||||
{
|
||||
await running.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PhaseState(TestId Test, string Kind, RunningBlock Running);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Probe;
|
||||
|
||||
/// <summary>
|
||||
/// Probe 要连接的 Agent 列表:由面板作为期望状态(<c>probe_agents</c>)直接下发;面板连不上时用上一次保存的 <c>agents.json</c>。
|
||||
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
|
||||
/// </summary>
|
||||
public sealed class AgentDirectory
|
||||
{
|
||||
public const string FileName = "agents.json";
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly ConfigStore _store;
|
||||
private readonly INodeLog _log;
|
||||
private readonly TrustAnchor _trust;
|
||||
private List<KnownAgent> _agents = [];
|
||||
private long _version;
|
||||
|
||||
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
|
||||
{
|
||||
_trust = trust ?? TrustAnchor.None;
|
||||
_store = store;
|
||||
_log = log;
|
||||
string cache = Path.Combine(configDirectory, FileName);
|
||||
if (File.Exists(cache))
|
||||
{
|
||||
try
|
||||
{
|
||||
AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache);
|
||||
_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, waiting for the panel", ("err", ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>列表变化(调和连接)。</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
public long Version
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<KnownAgent> Agents
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return [.. _agents];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
|
||||
public void Apply(ProbeAgentList result)
|
||||
{
|
||||
var next = new List<KnownAgent>();
|
||||
_trust.Update(result.TrustChain);
|
||||
foreach (AgentEndpoint agent in result.Agents)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(agent.Host))
|
||||
{
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
|
||||
{
|
||||
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
|
||||
}
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
if (result.Version < _version || (result.Version == _version && Same(next, _agents)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_version = result.Version;
|
||||
_agents = next;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_store.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new AgentsCache { Version = result.Version, Agents = next }, ProbeConfigContext.Default.AgentsCache));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_log.Warn("could not save agents.json", ("err", ex.Message));
|
||||
}
|
||||
|
||||
_log.Info("agent list updated", ("version", result.Version), ("agents", next.Count));
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
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(KnownAgent a, KnownAgent b) =>
|
||||
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey;
|
||||
}
|
||||
Loaded 100 of 534 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user