From ad5efc62ffd24a8a888f96db45e66b703062567d Mon Sep 17 00:00:00 2001 From: justme Date: Wed, 21 Oct 2026 11:55:36 +0800 Subject: [PATCH] agent: unify listening and dialing with directed mutual measurements Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry. Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup. --- MoShiro.slnx | 7 +- backend/MoShiro.slnx | 7 +- backend/README.md | 2 +- backend/agent/README.md | 20 +- backend/agent/src/MoShiro.Agent/AgentApp.cs | 155 ++-- .../agent/src/MoShiro.Agent/AgentConfig.cs | 15 +- .../MoShiro.Agent/Chunks/ChunkAssembler.cs | 16 +- .../src/MoShiro.Agent/DataPlane/Registries.cs | 62 +- .../src/MoShiro.Agent/Dial/ControlBackoff.cs} | 6 +- .../src/MoShiro.Agent/Dial/DialConnection.cs | 386 +++++++++ .../src/MoShiro.Agent/Dial/DialDirectory.cs | 116 +++ .../src/MoShiro.Agent/Dial/DialEndpoint.cs | 40 + .../src/MoShiro.Agent/Dial/DialSession.cs | 205 +++++ .../src/MoShiro.Agent/Dial/DialerHost.cs | 203 +++++ .../src/MoShiro.Agent/Dial/LinkFaults.cs | 44 ++ .../src/MoShiro.Agent/Dial/LinkStatus.cs | 247 ++++++ .../MoShiro.Agent/Dial/QuicRedialBackoff.cs | 23 + .../src/MoShiro.Agent/Dial}/Support.cs | 28 +- .../agent/src/MoShiro.Agent/Dial/TaskLink.cs | 162 ++++ .../src/MoShiro.Agent}/OpenWrtUpdater.cs | 6 +- .../agent/src/MoShiro.Agent/Peer/PeerHub.cs | 114 +-- .../src/MoShiro.Agent/Tasks/TargetRunners.cs | 4 +- .../src/MoShiro.Agent/Tasks/TaskManager.cs | 67 +- .../src/MoShiro.Agent/Tests/TestRunner.cs | 54 +- .../ControlBackoffTests.cs} | 9 +- .../DialDirectoryTests.cs} | 56 +- .../MoShiro.Agent.Tests/LinkStateTests.cs | 110 +++ .../MoShiro.Agent.Tests.csproj} | 2 +- .../OpenWrtUpdaterTests.cs | 2 +- .../MoShiro.Agent.Tests/TaskLinkTests.cs | 58 ++ backend/fleet/README.md | 4 +- backend/fleet/src/Fleet.Core/NodeSupport.cs | 2 +- backend/fleet/src/Fleet.Hosting/Hosting.cs | 2 +- backend/fleet/src/Fleet.Hosting/OwnerGuard.cs | 2 +- backend/fleet/src/Fleet.Link/Peer.cs | 18 +- backend/fleet/src/Fleet.Link/Transports.cs | 2 +- backend/fleet/src/Fleet.Rpc/FleetMethods.cs | 4 +- backend/fleet/src/Fleet.Rpc/MethodTable.cs | 2 +- backend/fleet/src/Fleet.Rpc/PeerMethods.cs | 2 +- backend/fleet/src/Fleet.Rpc/RpcRouter.cs | 2 +- backend/fleet/src/Fleet.Rpc/RpcSession.cs | 2 +- .../FleetServerOptions.cs | 2 +- .../src/Fleet.Server.Link/FleetLinkHost.cs | 2 +- .../Fleet.Server.Link/FleetServerBuilder.cs | 4 +- .../Fleet.Server.Link/NodeConnection.Send.cs | 9 +- .../src/Fleet.Server.Link/NodeConnection.cs | 20 +- backend/fleet/tests/Fleet.Tests/KnockTests.cs | 28 +- .../Fleet.Tests/LinkConnectTimeoutTests.cs | 12 +- backend/fleet/tests/Fleet.Tests/LinkTests.cs | 66 +- .../tests/Fleet.Tests/MethodDirectionTests.cs | 14 +- .../tests/Fleet.Tests/OwnerGuardTests.cs | 2 +- backend/fleet/tools/fleet-enroll/Program.cs | 2 +- backend/openapi/v1.json | 747 +++++------------- backend/probe/Directory.Build.props | 21 - backend/probe/README.md | 67 -- .../src/MoShiro.Probe/Link/AgentConnection.cs | 688 ---------------- .../src/MoShiro.Probe/Link/AgentDirectory.cs | 116 --- .../src/MoShiro.Probe/MoShiro.Probe.csproj | 28 - backend/probe/src/MoShiro.Probe/ProbeApp.cs | 331 -------- .../probe/src/MoShiro.Probe/ProbeConfig.cs | 98 --- backend/probe/src/MoShiro.Probe/Program.cs | 62 -- .../Blocks/BlockCatalog.cs | 13 +- .../shared/MoShiro.Measurement/Blocks/Bulk.cs | 12 +- .../shared/MoShiro.Measurement/Blocks/Echo.cs | 14 +- .../MoShiro.Measurement/Blocks/Session.cs | 8 +- .../MoShiro.Measurement/Blocks/Train.cs | 12 +- .../MoShiro.Measurement/Core/Contracts.cs | 12 +- .../MoShiro.Measurement/Core/MonoClock.cs | 2 +- .../MoShiro.Measurement/Core/RowStore.cs | 6 +- .../MoShiro.Measurement/Core/RunningBlock.cs | 2 +- .../Merging/ChunkFinalizer.cs | 16 +- .../Merging/FragmentMerger.cs | 62 +- .../Net/MsQuicConnection.cs | 4 +- .../MoShiro.Measurement/Net/QuicAlpn.cs | 2 +- .../Net/QuicDatagramTransport.cs | 68 +- backend/shared/MoShiro.Measurement/Net/Tcp.cs | 2 +- .../shared/MoShiro.Measurement/Net/TcpInfo.cs | 4 +- .../Platform/HostSampler.cs | 2 +- .../Platform/ReportingSection.cs | 2 +- .../MoShiro.Protocol/Catalog/KindSpec.cs | 28 +- .../MoShiro.Protocol/Catalog/Kinds/Kinds.cs | 18 +- .../shared/MoShiro.Protocol/Ids/MoShiroIds.cs | 15 - backend/shared/MoShiro.Protocol/IpVersions.cs | 4 +- .../shared/MoShiro.Protocol/MoShiroErrors.cs | 9 +- .../shared/MoShiro.Protocol/ProtocolLimits.cs | 2 +- .../shared/MoShiro.Protocol/Raw/RawChunk.cs | 32 +- .../MoShiro.Protocol/Raw/RawChunkCodec.cs | 10 +- .../Rpc/MoShiroMethodTable.cs | 37 +- .../shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs | 368 +++++---- .../MoShiro.Protocol/Rpc/NodeRuntimeStatus.cs | 4 +- .../shared/MoShiro.Protocol/Tasks/TaskSet.cs | 8 +- .../Wire/MeasurementDatagram.cs | 2 +- .../shared/MoShiro.Protocol/Wire/TcpWire.cs | 2 +- .../Ipv6DiagnosticsTests.cs | 2 +- .../MeasurementTests.cs | 62 +- .../MsQuicConnectionTests.cs | 11 +- .../RestartUpdaterTests.cs | 2 +- .../MoShiro.Protocol.Tests/CatalogTests.cs | 10 +- .../NodeRuntimeStatusTests.cs | 10 +- .../RawChunkCodecTests.cs | 14 +- .../Engine/ChunkView.cs | 10 +- .../Engine/TestEvaluator.cs | 4 +- .../Metrics/LinkMetrics.cs | 8 +- .../MoShiro.Backend.Analysis/Ops/EchoOps.cs | 2 +- .../Ops/SessionOps.cs | 8 +- .../Admin/AgentEndpoints.cs | 98 +-- .../Admin/BuildEndpoints.cs | 9 +- .../Admin/LinkEndpoints.cs | 147 ++-- .../Admin/SystemEndpoints.cs | 2 +- .../Admin/TaskEndpoints.cs | 12 +- .../Admin/TrustEndpoints.cs | 2 +- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 76 +- .../MoShiro.Backend.Api/Common/Problems.cs | 6 +- .../Common/QueryHelpers.cs | 4 +- .../Contracts/AdminDtos.cs | 42 +- .../Contracts/ApiJsonContext.cs | 3 - .../Contracts/PublicDtos.cs | 32 +- .../src/MoShiro.Backend.Api/InstallScripts.cs | 16 +- .../MoShiro.Backend.Api.csproj | 2 - .../src/MoShiro.Backend.Api/OpsEndpoints.cs | 21 +- .../Public/HostEndpoints.cs | 41 +- .../Public/LiveEndpoints.cs | 32 +- .../Public/NodeEndpoints.cs | 16 +- .../Public/QueryEndpoints.cs | 12 +- .../Admin/JoinTokenService.cs | 52 +- .../Admin/SettingsService.cs | 6 +- .../Agents/AgentEventService.cs | 12 +- .../Agents/AgentRuntime.cs | 52 +- .../Agents/AgentService.cs | 83 +- .../Agents/HostStatusService.cs | 2 +- .../Agents/LinkRuntime.cs | 47 ++ .../Agents/LinkService.cs | 25 +- .../Agents/MoShiroFleetHooks.cs | 223 ++---- .../Agents/NodeOwnerService.cs | 2 +- .../Agents/NodeRuntimeLogService.cs | 2 +- .../Agents/ProbeQuicRuntime.cs | 60 -- .../Agents/ProbeRuntime.cs | 22 - .../Agents/ProbeService.cs | 175 ---- .../ApplicationServiceCollectionExtensions.cs | 4 +- .../BackendOptions.cs | 8 +- .../Models/AgentModels.cs | 33 +- .../Models/TaskAndTestModels.cs | 2 +- .../Nodes/NodeInfo.cs | 9 +- .../Nodes/NodeInfoService.cs | 32 +- .../Ports/IAgentGateway.cs | 12 +- .../Ports/StorePorts.cs | 25 +- .../Query/Lines.cs | 82 +- .../Query/QueryService.cs | 22 +- .../Query/RowLoader.cs | 8 +- .../Tasks/TaskPlanner.cs | 19 +- .../Tests/Plans.cs | 2 +- .../Tests/TestService.cs | 24 +- .../Updates/BackendUpdates.cs | 2 +- .../Updates/ReleasePoller.cs | 4 +- .../Updates/ReleaseService.cs | 11 +- .../DialTargetsStateProvider.cs | 35 + .../MoShiro.Backend.Gateway/FleetGateway.cs | 8 +- .../GatewayServiceCollectionExtensions.cs | 12 +- .../ProbeAgentsStateProvider.cs | 33 - .../TaskSetStateProvider.cs | 7 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 539 +++++-------- .../SqliteAgentStore.cs | 71 +- .../SqliteConfigStores.cs | 38 - .../SqliteNodeMetaStore.cs | 12 +- .../SqliteTestStore.cs | 22 +- .../StorageServiceCollectionExtensions.cs | 1 - backend/src/MoShiro.Backend/BackendHost.cs | 2 +- backend/src/MoShiro.Backend/Program.cs | 2 +- .../ModelAndTestEvaluatorTests.cs | 2 +- .../MoShiro.Backend.Analysis.Tests/OpTests.cs | 10 +- .../MoShiro.Backend.Tests/AddressTests.cs | 8 +- .../AgentDataPlaneTests.cs | 26 +- .../AgentEndToEndTests.cs | 110 +-- .../MoShiro.Backend.Tests/AgentHarness.cs | 32 +- .../MoShiro.Backend.Tests/AgentMeshTests.cs | 46 ++ .../AgentTaskSetEndToEndTests.cs | 10 +- .../MoShiro.Backend.Tests/AotSmokeTests.cs | 27 +- .../ArchitectureTests.cs | 10 +- .../tests/MoShiro.Backend.Tests/AuditTests.cs | 2 +- .../MoShiro.Backend.Tests/BackendFixture.cs | 42 +- .../DialerEndToEndTests.cs | 90 +++ .../MoShiro.Backend.Tests/DialerHarness.cs | 62 ++ .../EnrollmentAndTaskSetTests.cs | 62 +- .../tests/MoShiro.Backend.Tests/HostTests.cs | 49 +- .../IngestAndQueryTests.cs | 4 +- .../MoShiro.Backend.Tests/InstallTests.cs | 55 +- .../tests/MoShiro.Backend.Tests/Ipv6Tests.cs | 32 +- .../tests/MoShiro.Backend.Tests/LinkTests.cs | 75 +- .../tests/MoShiro.Backend.Tests/LiveTests.cs | 4 +- .../MoShiro.Backend.Tests.csproj | 1 - .../MoShiro.Backend.Tests/NodeInfoTests.cs | 44 +- .../NodeRuntimeLogTests.cs | 2 +- .../OnDemandTestsTests.cs | 26 +- .../OperationsAndSecurityTests.cs | 8 +- .../tests/MoShiro.Backend.Tests/OwnerTests.cs | 6 +- .../ProbeEndToEndTests.cs | 133 ---- .../MoShiro.Backend.Tests/ProbeHarness.cs | 69 -- .../ProtocolDeclarationTests.cs | 26 +- .../MoShiro.Backend.Tests/ReleaseFeedTests.cs | 2 +- .../ReleaseOfferTests.cs | 2 +- .../MoShiro.Backend.Tests/StorageTests.cs | 2 +- .../tests/MoShiro.Backend.Tests/TrustTests.cs | 2 +- .../MoShiro.Backend.Tests/UpdateTests.cs | 8 +- .../MoShiro.TestSupport/AgentSimulator.cs | 47 +- .../tests/MoShiro.TestSupport/ChunkFactory.cs | 30 +- .../{ProbeSimulator.cs => DialerSimulator.cs} | 71 +- 206 files changed, 4202 insertions(+), 4964 deletions(-) rename backend/{probe/src/MoShiro.Probe/Link/ProbeReconnectBackoff.cs => agent/src/MoShiro.Agent/Dial/ControlBackoff.cs} (76%) create mode 100644 backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs create mode 100644 backend/agent/src/MoShiro.Agent/Dial/DialDirectory.cs create mode 100644 backend/agent/src/MoShiro.Agent/Dial/DialEndpoint.cs create mode 100644 backend/agent/src/MoShiro.Agent/Dial/DialSession.cs create mode 100644 backend/agent/src/MoShiro.Agent/Dial/DialerHost.cs create mode 100644 backend/agent/src/MoShiro.Agent/Dial/LinkFaults.cs create mode 100644 backend/agent/src/MoShiro.Agent/Dial/LinkStatus.cs create mode 100644 backend/agent/src/MoShiro.Agent/Dial/QuicRedialBackoff.cs rename backend/{probe/src/MoShiro.Probe/Link => agent/src/MoShiro.Agent/Dial}/Support.cs (66%) create mode 100644 backend/agent/src/MoShiro.Agent/Dial/TaskLink.cs rename backend/{probe/src/MoShiro.Probe => agent/src/MoShiro.Agent}/OpenWrtUpdater.cs (94%) rename backend/{probe/tests/MoShiro.Probe.Tests/ProbeReconnectBackoffTests.cs => agent/tests/MoShiro.Agent.Tests/ControlBackoffTests.cs} (79%) rename backend/{probe/tests/MoShiro.Probe.Tests/ProbeTests.cs => agent/tests/MoShiro.Agent.Tests/DialDirectoryTests.cs} (52%) create mode 100644 backend/agent/tests/MoShiro.Agent.Tests/LinkStateTests.cs rename backend/{probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj => agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj} (77%) rename backend/{probe/tests/MoShiro.Probe.Tests => agent/tests/MoShiro.Agent.Tests}/OpenWrtUpdaterTests.cs (98%) create mode 100644 backend/agent/tests/MoShiro.Agent.Tests/TaskLinkTests.cs delete mode 100644 backend/probe/Directory.Build.props delete mode 100644 backend/probe/README.md delete mode 100644 backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs delete mode 100644 backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs delete mode 100644 backend/probe/src/MoShiro.Probe/MoShiro.Probe.csproj delete mode 100644 backend/probe/src/MoShiro.Probe/ProbeApp.cs delete mode 100644 backend/probe/src/MoShiro.Probe/ProbeConfig.cs delete mode 100644 backend/probe/src/MoShiro.Probe/Program.cs create mode 100644 backend/src/MoShiro.Backend.Application/Agents/LinkRuntime.cs delete mode 100644 backend/src/MoShiro.Backend.Application/Agents/ProbeQuicRuntime.cs delete mode 100644 backend/src/MoShiro.Backend.Application/Agents/ProbeRuntime.cs delete mode 100644 backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs create mode 100644 backend/src/MoShiro.Backend.Gateway/DialTargetsStateProvider.cs delete mode 100644 backend/src/MoShiro.Backend.Gateway/ProbeAgentsStateProvider.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/AgentMeshTests.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/DialerEndToEndTests.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/DialerHarness.cs delete mode 100644 backend/tests/MoShiro.Backend.Tests/ProbeEndToEndTests.cs delete mode 100644 backend/tests/MoShiro.Backend.Tests/ProbeHarness.cs rename backend/tests/MoShiro.TestSupport/{ProbeSimulator.cs => DialerSimulator.cs} (88%) diff --git a/MoShiro.slnx b/MoShiro.slnx index f15c43c..fb44308 100644 --- a/MoShiro.slnx +++ b/MoShiro.slnx @@ -27,11 +27,8 @@ - - - - - + + diff --git a/backend/MoShiro.slnx b/backend/MoShiro.slnx index e453cc3..30449fb 100644 --- a/backend/MoShiro.slnx +++ b/backend/MoShiro.slnx @@ -27,11 +27,8 @@ - - - - - + + diff --git a/backend/README.md b/backend/README.md index b6a65f0..d1d39c2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -38,7 +38,7 @@ dotnet publish src/MoShiro.Backend -c Release -r -p:PublishAot=true 例如 `` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend ` 可构建单个平台的 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 镜像中。 ## 本地运行 diff --git a/backend/agent/README.md b/backend/agent/README.md index 38712f2..163510f 100644 --- a/backend/agent/README.md +++ b/backend/agent/README.md @@ -1,17 +1,19 @@ # MoShiro Agent -Agent 是部署在 VPS 或其他可达网络节点上的采集进程。它向 Backend 注册并保持连接,接受 Probe 的测量会话,执行线路任务中的 Agent 侧工作,再将两端数据合并后上报。 - -Agent 仅开放配置的控制和数据端口。当前版本不提供远程终端、命令执行或文件管理;本地配置中的网络范围、速率和并发限制由节点自行执行,Backend 下发的任务不能越过这些限制。 - -## 工作流程 +Agent 是统一的采集节点,默认同时监听和拨号。每个 Agent 独立连接 Backend,接收任务集和拨号目标;节点之间通过 TLS 控制会话、QUIC Datagram 和 TCP 数据连接完成测量。 ```text -Probe ──TLS 控制 / QUIC Datagram、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`)。从 `backend/` 目录构建: @@ -55,7 +57,7 @@ IPv6 线路需要 Agent 实际绑定 IPv6。新配置的默认 `listen.bind` 为 ## 设计说明 -- 每个 Probe 使用独立的 TLS 会话和数据密钥;Probe 断开时对应的会话资源会释放。 +- 每个 拨号方 使用独立的 TLS 会话和数据密钥;拨号方 断开时对应的会话资源会释放。 - 数据面会校验会话认证、重放窗口和速率限制;无效数据包会静默丢弃。 - 测量引擎和协议由 `backend/shared/` 提供;节点身份、注册和连接由 `backend/fleet/` 提供。 - 详细安全边界与拓扑见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md),协议说明见 [`../../docs/design/protocol.md`](../../docs/design/protocol.md),部署见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。 diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index f368007..a3880a8 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -10,13 +10,17 @@ using MoShiro.Protocol; namespace MoShiro.Agent; -/// Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。 +/// +/// 节点的组装点:与 Backend 的链路、监听侧(数据面和点对点监听,listen.enabled 为 false 时不启动)、拨号侧(Backend 下发的拨号目标)都在这里接起来。 +/// 没有全局状态,测试可以在同一进程里启动多个。 +/// 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; @@ -33,6 +37,8 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable private readonly AgentAdmission _admission; private readonly Outbox _outbox = new(64 << 20); private readonly RestartUpdater? _update; + private readonly OpenWrtUpdater? _nativeUpdate; + private readonly DialerHost _dialer; private readonly BackendUplink _uplink; private readonly QuicDatagramTransport _datagrams; private readonly TcpAcceptor _tcp; @@ -50,7 +56,9 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable _ownedRestart = restart is null ? new RestartController() : null; _restart = restart ?? _ownedRestart!; _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; @@ -64,20 +72,21 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable 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); - _datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.EffectiveQuicPorts, identity.Certificate); + _datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.QuicPorts, identity.Certificate); // 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers _trust = TrustAnchor.Load(config.Fleet.IdentityDir, log); + _dialer = new DialerHost(new DialEnv(identity.PeerIdentity, identity.NodeId.Value, config.Limits, new DialDirectory(new ConfigStore(dataDirectory), dataDirectory, log, _trust), _clock, time, log), 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) + Session = () => null, // 每个拨号方会话各用自己绑定的环境(BlockEnv.WithSession) Datagrams = _datagrams, TcpListen = _tcp, Admission = _admission, @@ -87,14 +96,14 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable }; _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, () => _datagrams.BoundPorts, () => _tcp.BoundPorts, _trust); + _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.DatagramRemoteEndPoint?.ToString()).AsTask(); + _hub.OnDialerLost = dialer => _tests.DialerLost(dialer); + _admission.OnEndpointChanged = dialer => _ = _uplink.EmitEventAsync(AgentEventKinds.EndpointChanged, dialer.DatagramRemoteEndPoint?.ToString()).AsTask(); _peers = new PeerListener( new PeerListenerOptions @@ -122,6 +131,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable return; } + _dialer.Dispose(); _datagrams.Dispose(); _ownedRestart?.Dispose(); } @@ -133,6 +143,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable return; } + _dialer.Dispose(); await _datagrams.DisposeAsync().ConfigureAwait(false); _ownedRestart?.Dispose(); } @@ -152,12 +163,15 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable public FleetLink Link => _link; - /// 对等面(Probe 连进来的会话)的路由器,测试用。 + /// 拨号侧(测试和状态查看用)。 + public DialerHost Dialer => _dialer; + + /// 对等面(拨号方连进来的会话)的路由器,测试用。 public RpcRouter PeerRouter => _hub.Router; - /// 期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。 - public bool IsPeerKnown(string probeId, string publicKeyBase64) => - _tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64; + /// 期望状态里是否已经有这个拨号方的公钥(测试用:握手会按它校验)。 + public bool IsPeerKnown(string dialerId, string publicKeyBase64) => + _tasks.LookupPeerKey(dialerId) is { } key && Convert.ToBase64String(key) == publicKeyBase64; public TestRunner Tests => _tests; @@ -165,24 +179,35 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable { _log.Info("moshiro agent starting", MoShiro.Protocol.BuildInfo.Fields); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct); - // 数据面和控制端口先开始监听,再连接 Backend:hello 里上报的是实际端口 - _datagrams.Bind(); - _tcp.Bind(); - Task quic = _datagrams.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(quic, 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), ("quic", string.Join(',', QuicPorts)), ("tcp", string.Join(',', TcpPorts))); try { - await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), ReportHostAsync(linked.Token), _chunks.RunTrafficReportingAsync(linked.Token)); + await Task.WhenAll(quic, tcp, peers, _link.RunAsync(linked.Token), _dialer.RunAsync(linked.Token), ReportHostAsync(linked.Token), _chunks.RunTrafficReportingAsync(linked.Token)); } finally { @@ -227,11 +252,11 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable AllowedQuicPorts = _datagrams.BoundPorts, AllowedTcpPorts = _tcp.BoundPorts, }, - BlockCatalog.AgentKinds); + _listening ? BlockCatalog.AgentKinds : BlockCatalog.TargetKinds); private FleetLink BuildLink() { - var router = new RpcRouter(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Agent); + var router = new RpcRouter(RpcSide.Node, MoShiroMethodTable.Table); HashSet? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null; bool Allow(string method) => allowed is null || allowed.Contains(method); @@ -258,6 +283,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable router.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) => { _update?.Offer(offer); + _nativeUpdate?.Offer(); return ValueTask.CompletedTask; }); } @@ -280,17 +306,27 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) => { _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.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) })); + async (_, request, _) => Result.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.DialerId.Value) })); + } + + if (Allow(MoShiroMethods.LinkRetry)) + { + router.OnNotify(MoShiroMethods.LinkRetry, MoShiroJsonContext.Default.LinkRetryParams, (_, request, _) => + { + _dialer.RetryListener(request.ListenerId.Value); + return ValueTask.CompletedTask; + }); } var options = new FleetLinkOptions @@ -300,19 +336,32 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable ServerVerifier = _trust, NodeId = _identity.NodeId, NodeVersion = Version, - Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)], + 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.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log); + var link = new FleetLink( + options, + router, + [ + new StateHandler(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync), + new StateHandler(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; @@ -323,17 +372,11 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable 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, - ProbeQuicPorts = [.. QuicPorts], - LegacyProbeUdpPorts = [.. QuicPorts], LocalLimits = new LocalLimits { MaxTotalMbps = _config.Limits.MaxTotalMbps, @@ -341,8 +384,17 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable 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; @@ -356,24 +408,25 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable long packetsIn = _counters.PacketsIn; long packetsOut = _counters.PacketsOut; NodeLogBatch logBatch = _logBatchSource?.TakeBatch(16) ?? new NodeLogBatch([], 0); - var extra = new AgentHeartbeatExtra + UpdateStatus? updateStatus = _nativeUpdate?.Status() ?? _update?.Status(); + var extra = new NodeHeartbeatExtra { - RuntimeStatus = _update?.Status().State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Running, + 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?.Status(), + Update = updateStatus, RuntimeLogs = logBatch.Records.Count == 0 ? null : [.. logBatch.Records], DroppedRuntimeLogs = logBatch.Dropped == 0 ? null : logBatch.Dropped, }; _lastIn = packetsIn; _lastOut = packetsOut; _lastAtMs = nowMs; - return JsonSerializer.SerializeToElement(extra, MoShiroJsonContext.Default.AgentHeartbeatExtra); + return JsonSerializer.SerializeToElement(extra, MoShiroJsonContext.Default.NodeHeartbeatExtra); } } diff --git a/backend/agent/src/MoShiro.Agent/AgentConfig.cs b/backend/agent/src/MoShiro.Agent/AgentConfig.cs index 3f59b4b..129acb3 100644 --- a/backend/agent/src/MoShiro.Agent/AgentConfig.cs +++ b/backend/agent/src/MoShiro.Agent/AgentConfig.cs @@ -44,23 +44,20 @@ public sealed class FleetSection public sealed class ListenSection { + /// 是否接受拨号方的连接。关闭时节点只拨出:不开任何端口,在 Backend 的目录里也不是拨号目标(家宽和 NAT 后的节点用这个配置)。 + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + [JsonPropertyName("bind")] public string Bind { get; set; } = "::"; - /// Probe 控制通道(TLS,点对点会话)。 + /// 拨号方 控制通道(TLS,点对点会话)。 [JsonPropertyName("control_port")] public int ControlPort { get; set; } = 7443; [JsonPropertyName("quic_ports")] public List QuicPorts { get; set; } = [7443]; - /// Legacy config key accepted while deployments migrate to QUIC Datagram. - [JsonPropertyName("udp_ports")] - public List? LegacyUdpPorts { get; set; } - - [JsonIgnore] - public IReadOnlyList EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts; - [JsonPropertyName("tcp_ports")] public List TcpPorts { get; set; } = [7444]; } @@ -89,4 +86,6 @@ public sealed class PolicySection } [JsonSerializable(typeof(AgentConfig))] +[JsonSerializable(typeof(DialCache))] +[JsonSerializable(typeof(OpenWrtHealth))] public sealed partial class AgentConfigContext : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs b/backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs index a4eb2c6..4b6bbfd 100644 --- a/backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs +++ b/backend/agent/src/MoShiro.Agent/Chunks/ChunkAssembler.cs @@ -6,7 +6,7 @@ using MoShiro.Protocol; namespace MoShiro.Agent; /// -/// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。 +/// 拨号方 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。 /// 找不到对应流的列块(任务已经被撤销、测试已经结束)被丢弃并计数;这只会发生在配置变更的瞬间。 /// public sealed class ChunkAssembler @@ -33,20 +33,20 @@ public sealed class ChunkAssembler } /// - /// 处理某个 Probe 交来的一个列块(已经解码)。返回是否被接受。 - /// 块头里的 probe_id 以认证过的会话为准(Probe 自报的值不可信),别的 Probe 的流也不会被命中。 + /// 处理某个拨号方交来的一个列块(已经解码)。返回是否被接受。 + /// 块头里的 dialer_id 以认证过的会话为准(拨号方 自报的值不可信),别的拨号方的流也不会被命中。 /// - 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) { diff --git a/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs b/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs index 91a38d3..d0f28d1 100644 --- a/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs +++ b/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs @@ -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); /// -/// 一个已经认证的 Probe 会话:数据面的 session_id 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、 +/// 一个已经认证的拨号方会话:数据面的 session_id 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、 /// 每个流的重放窗口。数据面的包不绑定五元组,NAT 映射变化之后自动跟随新的源地址。 /// -public sealed class ProbeSession : DataSession +public sealed class DialerSession : DataSession { private readonly ConcurrentDictionary _windows = new(); private readonly ConcurrentDictionary _nonces = new(); private SocketAddress? _datagramSource; - public ProbeSession(PeerSessionInfo info) + public DialerSession(PeerSessionInfo info) : base(info.Credentials.SessionIdValue, info.Credentials.Key) { Info = info; @@ -54,20 +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 => ProbeId; + 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; - /// Probe 的程序版本(握手里自报,只用于展示)。 + /// 拨号方 的程序版本(握手里自报,只用于展示)。 public string? Version => Info.Version; - /// Probe 的远端地址(经过 NAT 之后的公网 IP)。 + /// 拨号方 的远端地址(经过 NAT 之后的公网 IP)。 public string? RemoteAddress => (Info.Remote as IPEndPoint)?.Address.ToString(); public long ConnectedAtUtcMs { get; init; } @@ -133,59 +133,59 @@ public sealed class ProbeSession : DataSession public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _); } -/// 每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。 +/// 每个拨号方的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。 public sealed class SessionRegistry : IDataSessions { private readonly object _gate = new(); - private readonly Dictionary _byProbe = new(StringComparer.Ordinal); - private readonly Dictionary _bySession = []; + private readonly Dictionary _byDialer = new(StringComparer.Ordinal); + private readonly Dictionary _bySession = []; - public IReadOnlyList All + public IReadOnlyList 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); } } - /// 登记新会话;返回同一个 Probe 的旧会话(如果有)。 - public ProbeSession? Replace(ProbeSession session) + /// 登记新会话;返回同一个拨号方的旧会话(如果有)。 + 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); } @@ -236,8 +236,8 @@ public sealed class StreamInfo public int? Phase { get; init; } - /// 这个流属于哪个 Probe 会话:别的会话的包即使带着这个流编号也不会被接受。 - public ProbeSession? Owner { get; init; } + /// 这个流属于哪个拨号方会话:别的会话的包即使带着这个流编号也不会被接受。 + public DialerSession? Owner { get; init; } public int Port { get; } @@ -290,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); @@ -314,11 +314,11 @@ public sealed class AgentAdmission : IAdmission } /// NAT 映射变化,源地址变了:上报 endpoint_changed。 - public Action? OnEndpointChanged { get; set; } + public Action? OnEndpointChanged { get; set; } 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; } @@ -328,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; diff --git a/backend/probe/src/MoShiro.Probe/Link/ProbeReconnectBackoff.cs b/backend/agent/src/MoShiro.Agent/Dial/ControlBackoff.cs similarity index 76% rename from backend/probe/src/MoShiro.Probe/Link/ProbeReconnectBackoff.cs rename to backend/agent/src/MoShiro.Agent/Dial/ControlBackoff.cs index 333364a..50e50a5 100644 --- a/backend/probe/src/MoShiro.Probe/Link/ProbeReconnectBackoff.cs +++ b/backend/agent/src/MoShiro.Agent/Dial/ControlBackoff.cs @@ -1,7 +1,7 @@ -namespace MoShiro.Probe; +namespace MoShiro.Agent; -/// Reconnect cadence for a single Probe-to-Agent path: 1, 2, 4, 8, 16, then five 32-second waits, then once per minute. -public sealed class ProbeReconnectBackoff +/// 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. +public sealed class ControlBackoff { private static readonly TimeSpan[] s_delays = [ diff --git a/backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs b/backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs new file mode 100644 index 0000000..8161c2c --- /dev/null +++ b/backend/agent/src/MoShiro.Agent/Dial/DialConnection.cs @@ -0,0 +1,386 @@ +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; + +/// 所有连接共用的东西。 +public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log); + +/// +/// 与一个 Agent 的点对点连接,分两层: +/// +/// 控制层:TCP+TLS 长连接。认证握手 → 同步时钟 → 按 Agent 下发的 line_task_set 运行积木 → 每 10 秒把已完成的行切成列块放进发件箱, +/// 发件箱和控制 RPC 共用这条连接。控制层断开才算与 Agent 失联:积木停止(数据面凭据属于控制会话),按 重拨。 +/// 任务层:TCP 类积木各自拨号;QUIC 数据通道由 在控制层在线期间单独维护,断开只重拨 QUIC。 +/// +/// 发件箱属于这条连接,不属于某次控制会话:控制层重连之后重发还没确认的数据块。状态与日志由 统一维护。 +/// +public sealed class DialConnection : IDisposable +{ + private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds); + private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30); + + /// Agent 的任何帧(时钟回复、任务集)都算存活;比时钟同步间隔长得多,容得下差线路上的黑洞,但不会让一个 Agent 已经丢掉的会话永远挂着。 + private static readonly TimeSpan s_idleLimit = TimeSpan.FromSeconds(120); + + private static readonly RpcRouter s_router = BuildRouter(); + + /// 对等面入站方法的路由器(只读,测试用)。 + public static RpcRouter 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; } + + /// 控制层在线,且 QUIC 数据通道可发送。 + 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; + + /// 当前线路状态(随心跳上报给面板)。 + public LinkHealth Health() => + _status.Snapshot(_outbox, _session?.TcpBlockCount ?? 0, Volatile.Read(ref _activeDatagrams)?.GetTransportSnapshot()); + + /// 管理端手动配对:控制层在退避就马上再拨;控制层在线而 QUIC 在重拨,就跳过 QUIC 的等待。 + 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(); + + /// 控制层循环:拨号,会话结束后按退避重拨。 + 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; + } + } + } + + /// 一次控制会话:建立 → 运行 → 结束。建立失败抛出异常;建立之后结束返回结束原因。 + private async Task 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 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); + 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, + }; + var session = new DialSession(blockEnv, clock, _outbox, _env); + var rpc = new RpcSession(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 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 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) + { + // 会话结束时的正常噪声 + } + } + + /// 回传数据:发件箱里的块经控制连接发给 Agent。 + private static async Task PumpAsync(RpcSession 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 rpc, ClockEstimator clock, int rounds, CancellationToken ct) + { + for (int i = 0; i < rounds; i++) + { + long sent = _env.Clock.NowUs(); + Result 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()); + } + } + } + + /// 切块(每 10 秒)、时钟同步(每 30 秒)和控制连接的存活检查。 + private async Task MaintainAsync(RpcSession 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 BuildRouter() => + new RpcRouter(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.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed"); + } + + result.Rejected.AddRange(await session.ApplyAsync(set)); + } + + return Result.Ok(result); + }) + .On( + MoShiroMethods.TestPhase, + MoShiroJsonContext.Default.TestPhaseStartParams, + MoShiroJsonContext.Default.TestPhaseStartResult, + (session, start, _) => ValueTask.FromResult(Result.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) }))) + .On( + MoShiroMethods.TestPhaseEnd, + MoShiroJsonContext.Default.TestPhaseEndParams, + MoShiroJsonContext.Default.TestPhaseEndResult, + async (session, end, ct) => Result.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; + }); +} diff --git a/backend/agent/src/MoShiro.Agent/Dial/DialDirectory.cs b/backend/agent/src/MoShiro.Agent/Dial/DialDirectory.cs new file mode 100644 index 0000000..88df560 --- /dev/null +++ b/backend/agent/src/MoShiro.Agent/Dial/DialDirectory.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Hosting; +using MoShiro.Protocol; + +namespace MoShiro.Agent; + +/// +/// 本节点要拨的监听节点列表:由面板作为期望状态(dial_targets)直接下发;面板连不上时用上一次保存的 dial_targets.json。 +/// 列表变化时用 写回 agents.json(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。 +/// +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 _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)); + } + } + } + + /// 列表变化(调和连接)。 + public event Action? Changed; + + public long Version + { + get + { + lock (_gate) + { + return _version; + } + } + } + + public IReadOnlyList Targets + { + get + { + lock (_gate) + { + return [.. _targets]; + } + } + } + + /// 应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。 + public void Apply(DialTargets result) + { + var next = new List(); + _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 a, List 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; +} diff --git a/backend/agent/src/MoShiro.Agent/Dial/DialEndpoint.cs b/backend/agent/src/MoShiro.Agent/Dial/DialEndpoint.cs new file mode 100644 index 0000000..32c5def --- /dev/null +++ b/backend/agent/src/MoShiro.Agent/Dial/DialEndpoint.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; +using MoShiro.Protocol; + +namespace MoShiro.Agent; + +/// 要拨的一个监听节点的一条路径(一个 IP 族):地址、端口和固定证书用的公钥。 +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 QuicPorts { get; set; } = [7443]; + + /// 监听方的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。 + [JsonPropertyName("public_key")] + public string PublicKey { get; set; } = ""; +} + +/// dial_targets.json:最近一次从面板同步到的拨号目标(配置,不是测量数据);面板连不上时节点靠它继续测量。 +public sealed class DialCache +{ + [JsonPropertyName("version")] + public long Version { get; set; } + + [JsonPropertyName("targets")] + public List Targets { get; set; } = []; +} diff --git a/backend/agent/src/MoShiro.Agent/Dial/DialSession.cs b/backend/agent/src/MoShiro.Agent/Dial/DialSession.cs new file mode 100644 index 0000000..adee1d9 --- /dev/null +++ b/backend/agent/src/MoShiro.Agent/Dial/DialSession.cs @@ -0,0 +1,205 @@ +using System.Collections.Concurrent; +using Fleet.Link; +using Fleet.Rpc; +using MoShiro.Measurement; +using MoShiro.Protocol; + +namespace MoShiro.Agent; + +/// 一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。 +public sealed class DialSession +{ + private const int MaxBlocks = 16; + + private readonly object _gate = new(); + private readonly Dictionary _lineBlocks = []; + private readonly Dictionary _lineKinds = []; + private readonly ConcurrentDictionary _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; + } + } + } + + /// 正在运行的 TCP 类线路积木数(它们自带套接字和重拨,不依赖 QUIC 数据通道)。 + public int TcpBlockCount + { + get + { + lock (_gate) + { + return _lineKinds.Values.Count(IsTcpKind); + } + } + } + + private static bool IsTcpKind(string kind) => kind is "tcp_echo" or "connect" or "hold" or "tcp_bulk"; + + /// 调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。 + public async Task> ApplyAsync(LineTaskSet set) + { + var rejected = new List(); + var wanted = new Dictionary(); + 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(); + 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 blocks; + lock (_gate) + { + blocks = [.. _lineBlocks.Values]; + } + + foreach (RunningBlock running in blocks) + { + running.Block.Flush(FlushMode.Settled); + } + } + + /// 按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。 + 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 == "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.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; + } + + /// 阶段结束:收尾、交出列块,返回块数。 + public async Task 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 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); +} diff --git a/backend/agent/src/MoShiro.Agent/Dial/DialerHost.cs b/backend/agent/src/MoShiro.Agent/Dial/DialerHost.cs new file mode 100644 index 0000000..4441eed --- /dev/null +++ b/backend/agent/src/MoShiro.Agent/Dial/DialerHost.cs @@ -0,0 +1,203 @@ +using System.Collections.Concurrent; +using Fleet.Core; +using MoShiro.Protocol; + +namespace MoShiro.Agent; + +/// +/// 拨号侧的组装点:每个拨号目标(监听节点 × IP 族)一个 。目标列表变化时只调和连接: +/// 新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。 +/// +public sealed class DialerHost : IDisposable +{ + private readonly DialEnv _env; + private readonly Dictionary _connections = []; + private readonly ConcurrentDictionary _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; + + /// 某条路径的控制会话是否已经建立、QUIC 数据通道是否可发送(测试和状态查看用)。 + 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; + } + } + + /// 每条路径当前的线路健康(随心跳上报)。 + public List SnapshotLinks() + { + DialConnection[] connections; + lock (_connections) + { + connections = [.. _connections.Values.Select(r => r.Connection)]; + } + + return [.. connections.Select(c => c.Health())]; + } + + /// 管理端手动重试:跳过这个监听节点所有路径的退避。目标列表还没到时,30 秒内到达就补上。 + 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 targets, CancellationToken ct) + { + var wanted = targets.ToDictionary(t => t.PathKey); + var stopped = new List(); + 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(); + } + } +} diff --git a/backend/agent/src/MoShiro.Agent/Dial/LinkFaults.cs b/backend/agent/src/MoShiro.Agent/Dial/LinkFaults.cs new file mode 100644 index 0000000..99a4992 --- /dev/null +++ b/backend/agent/src/MoShiro.Agent/Dial/LinkFaults.cs @@ -0,0 +1,44 @@ +using System.Net.Sockets; +using System.Security.Authentication; +using MoShiro.Protocol; + +namespace MoShiro.Agent; + +/// 控制连接在已建立之后停止应答:对端不认这个会话了,或者线路中断超过了空闲上限。 +public sealed class ControlSilentException(string message) : IOException(message); + +/// 把连接各阶段的异常归类成上报给面板的 :失败发生在哪个阶段、属于哪一类。 +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", + }; +} diff --git a/backend/agent/src/MoShiro.Agent/Dial/LinkStatus.cs b/backend/agent/src/MoShiro.Agent/Dial/LinkStatus.cs new file mode 100644 index 0000000..03936eb --- /dev/null +++ b/backend/agent/src/MoShiro.Agent/Dial/LinkStatus.cs @@ -0,0 +1,247 @@ +using Fleet.Core; +using Fleet.Link; +using MoShiro.Measurement; +using MoShiro.Protocol; + +namespace MoShiro.Agent; + +/// +/// 一条线路(本节点拨号到一个监听节点的一个 IP 族)的状态:控制层和 QUIC 数据通道各一个状态机,状态迁移只在这里发生、只在这里记日志。 +/// 控制层每次迁移写一条 link state;数据通道只在“可用 ↔ 不可用”的边界上写,重拨过程中的失败只计数。 +/// +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 _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(); + } + + /// 当前这次尝试的日志(带 attempt_id 等上下文);控制层每次重拨前由连接更新。 + 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), + ]); + } + } + + /// 记录一次 QUIC 握手的结果。 + 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(); +} diff --git a/backend/agent/src/MoShiro.Agent/Dial/QuicRedialBackoff.cs b/backend/agent/src/MoShiro.Agent/Dial/QuicRedialBackoff.cs new file mode 100644 index 0000000..995bd1b --- /dev/null +++ b/backend/agent/src/MoShiro.Agent/Dial/QuicRedialBackoff.cs @@ -0,0 +1,23 @@ +namespace MoShiro.Agent; + +/// +/// QUIC 数据通道的重拨节奏:1、2、3、5 秒,之后固定 5 秒。数据通道断开期间的测量是缺口,所以不像控制连接那样拉长到一分钟。 +/// 抖动由调用方通过 给出,避免多条线路同时重拨。 +/// +public sealed class QuicRedialBackoff +{ + private static readonly TimeSpan[] s_delays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5)]; + + private int _attempt; + + public int Attempts => _attempt; + + public TimeSpan Next(Random? random = null) + { + TimeSpan delay = s_delays[Math.Min(_attempt, s_delays.Length - 1)]; + _attempt++; + return random is null ? delay : delay * (0.8 + (0.4 * random.NextDouble())); + } + + public void Reset() => _attempt = 0; +} diff --git a/backend/probe/src/MoShiro.Probe/Link/Support.cs b/backend/agent/src/MoShiro.Agent/Dial/Support.cs similarity index 66% rename from backend/probe/src/MoShiro.Probe/Link/Support.cs rename to backend/agent/src/MoShiro.Agent/Dial/Support.cs index 253be3a..07dc92c 100644 --- a/backend/probe/src/MoShiro.Probe/Link/Support.cs +++ b/backend/agent/src/MoShiro.Agent/Dial/Support.cs @@ -4,11 +4,11 @@ using Fleet.Link; using MoShiro.Measurement; using MoShiro.Protocol; -namespace MoShiro.Probe; +namespace MoShiro.Agent; /// -/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 moshiro.clock 往返取中点,在最近 8 次里取往返最短的一次 -/// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。 +/// 估计监听方单调时钟 − 拨号方单调时钟:每次 moshiro.clock 往返取中点,在最近 8 次里取往返最短的一次 +/// (往返越短,对称性假设的误差越小)。拨号方的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。 /// 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 } /// -/// 把 Probe 一侧的行交给这个 Agent:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。 +/// 把拨号方一侧的行交给监听方:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。 /// 发件箱的内容在连接断开重连之后重发;发件箱满了丢弃最旧的。 /// 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) { diff --git a/backend/agent/src/MoShiro.Agent/Dial/TaskLink.cs b/backend/agent/src/MoShiro.Agent/Dial/TaskLink.cs new file mode 100644 index 0000000..a5ff057 --- /dev/null +++ b/backend/agent/src/MoShiro.Agent/Dial/TaskLink.cs @@ -0,0 +1,162 @@ +using Fleet.Core; +using MoShiro.Measurement; +using MoShiro.Protocol; + +namespace MoShiro.Agent; + +/// +/// 任务层的 QUIC 数据通道:控制层在线期间在后台维持一条 QUIC 连接,断开后按 重拨。 +/// 重拨期间控制连接和 TCP 类积木不受影响;UDP 类积木的发送在通道不可用时按各自的规则处理。 +/// +public sealed class TaskLink : IDisposable +{ + /// QUIC 连接可用至少这么久才算稳定,重拨节奏随之复位。刚连上就断的通道不会把间隔重置回 1 秒。 + private static readonly TimeSpan s_stableAfter = TimeSpan.FromSeconds(10); + + /// 一直在发测量报文、却这么久没有收到任何一个:QUIC 路径已经是黑洞,主动关闭重拨。 + 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(); + + /// 管理端手动重试:跳过当前的重拨等待。 + 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; + } + } + } + + /// 连接可用期间的看护:连接关闭(对端关闭、空闲超时)或数据面静默时返回原因。 + private async Task 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) + { + // 等待被另一条路径或停止打断 + } + } +} diff --git a/backend/probe/src/MoShiro.Probe/OpenWrtUpdater.cs b/backend/agent/src/MoShiro.Agent/OpenWrtUpdater.cs similarity index 94% rename from backend/probe/src/MoShiro.Probe/OpenWrtUpdater.cs rename to backend/agent/src/MoShiro.Agent/OpenWrtUpdater.cs index 3abbe2c..98e86eb 100644 --- a/backend/probe/src/MoShiro.Probe/OpenWrtUpdater.cs +++ b/backend/agent/src/MoShiro.Agent/OpenWrtUpdater.cs @@ -5,7 +5,7 @@ using Fleet.Hosting; using MoShiro.Measurement; using MoShiro.Protocol; -namespace MoShiro.Probe; +namespace MoShiro.Agent; /// /// Native supervisor bridge: writes only fixed wake-up flags and a health beacon in tmpfs. @@ -29,7 +29,7 @@ public sealed class OpenWrtUpdater public static OpenWrtUpdater? FromEnvironment(TimeProvider time, INodeLog log) => Environment.GetEnvironmentVariable(RestartUpdater.EnvironmentVariable) == "openwrt" - ? new OpenWrtUpdater("/var/run/moshiro-probe", "/var/run/moshiro-update/status.json", time, log) + ? new OpenWrtUpdater("/var/run/moshiro-agent", "/var/run/moshiro-update/status.json", time, log) : null; public void Offer() => Signal("check.request"); @@ -54,7 +54,7 @@ public sealed class OpenWrtUpdater try { var health = new OpenWrtHealth(BuildInfo.Version, Environment.ProcessId, _time.GetUtcNow().ToUnixTimeSeconds()); - _runtime.Write("health.json", JsonSerializer.SerializeToUtf8Bytes(health, ProbeConfigContext.Default.OpenWrtHealth)); + _runtime.Write("health.json", JsonSerializer.SerializeToUtf8Bytes(health, AgentConfigContext.Default.OpenWrtHealth)); if (!File.Exists(_statusPath) || new FileInfo(_statusPath).Length > 8192) { return null; diff --git a/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs b/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs index 8d73405..89c23c2 100644 --- a/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs +++ b/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs @@ -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? 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,14 +71,14 @@ public sealed class BackendUplink } /// -/// Probe 的点对点会话:每次握手成功之后登记会话(同一个 Probe 的新会话取代它自己的旧会话,不同 Probe 的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收 Probe 的列块并交给 -/// 。会话结束时通知 Backend,并让正在运行的测试以 probe_offline 结束。 +/// 拨号方 的点对点会话:每次握手成功之后登记会话(同一个拨号方的新会话取代它自己的旧会话,不同拨号方的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收拨号方的列块并交给 +/// 。会话结束时通知 Backend,并让正在运行的测试以 dialer_offline 结束。 /// public sealed class PeerHub { private const int DedupeWindow = 1024; - /// 超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。 + /// 超过这么久没有收到拨号方的任何帧就认为会话已死(拨号方 每 30 秒至少发一次时钟同步)。 /// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。 private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180); @@ -106,15 +106,15 @@ public sealed class PeerHub _log = log; } - /// Probe 会话断开(这个 Probe 上的测试据此中止)。 - public Action? OnProbeLost { get; set; } + /// 拨号方 会话断开(这个拨号方上的测试据此中止)。 + public Action? OnDialerLost { get; set; } public async Task OnSessionAsync(PeerSessionInfo info, IRpcTransport transport, CancellationToken ct) { - var probe = new ProbeSession(info) { ConnectedAtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() }; - var log = new ContextNodeLog(_log, ("probe", probe.ProbeId), ("ip_version", probe.IpVersion), ("control_id", Guid.NewGuid().ToString("N"))); - var rpc = new RpcSession(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(transport, _router, new PeerContext(dialer, _clock), _time); + dialer.Rpc = rpc; var seen = new HashSet(); var order = new Queue(); @@ -137,7 +137,7 @@ public sealed class PeerHub Result decoded = RawChunkCodec.Decode(payload.Span); if (decoded.TryGet(out RawChunk? fragment, out FleetError? error)) { - _chunks.Accept(fragment, probe); + _chunks.Accept(fragment, dialer); } else { @@ -149,23 +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 probe control session"); + 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, log, linked.Token); - log.Info("probe connected", ("phase", "control_session"), ("version", info.Version ?? ""), ("remote", info.Remote)); - 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; @@ -176,31 +176,31 @@ public sealed class PeerHub } catch (Exception ex) { - probe.CloseReason ??= "control_error"; - log.Warn("probe control session failed", ("exception", ex.GetType().Name), ("err", ex.Message)); + 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", ("phase", "control_session"), - ("reason", probe.CloseReason ?? (ct.IsCancellationRequested ? "service_stopping" : "peer_closed")), - ("duration_ms", _time.GetUtcNow().ToUnixTimeMilliseconds() - probe.ConnectedAtUtcMs), - ("datagram_bound", probe.DatagramConnection is not null), ("data_remote", probe.DatagramRemoteEndPoint)); - 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)); } } /// - /// Probe 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 没有收到任何东西, - /// 说明 Probe 已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 probe_disconnected,而不是一直显示在线。 + /// 拨号方 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 没有收到任何东西, + /// 说明拨号方已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 dialer_disconnected,而不是一直显示在线。 /// - private async Task WatchIdleAsync(ProbeSession probe, RpcSession rpc, INodeLog log, CancellationToken ct) + private async Task WatchIdleAsync(DialerSession dialer, RpcSession rpc, INodeLog log, CancellationToken ct) { long lastSummary = _time.GetTimestamp(); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), _time); @@ -210,17 +210,17 @@ public sealed class PeerHub { if (rpc.IdleFor > IdleLimit) { - probe.CloseReason = "idle_timeout"; - log.Warn("probe session is silent, closing it", ("phase", "control_session"), ("idle_s", (long)rpc.IdleFor.TotalSeconds), ("limit_s", IdleLimit.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("probe session summary", ("phase", "control_session"), ("remote", probe.Info.Remote), - ("control_idle_s", (long)rpc.IdleFor.TotalSeconds), ("datagram_bound", probe.DatagramConnection is not null), - ("data_remote", probe.DatagramRemoteEndPoint)); + 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)); } } } @@ -230,23 +230,23 @@ public sealed class PeerHub } } - /// 当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。 - public IReadOnlyList ConnectedProbes() => [.. _sessions.All.Select(Describe)]; + /// 当前连着的拨号方(心跳和 Backend 重连之后的补报都用它)。 + public IReadOnlyList 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 }; - /// 向一个 Probe 下发它的线路任务集(Probe 连上时由 先发一份)。 - public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None); + /// 向一个拨号方下发它的线路任务集(拨号方 连上时由 先发一份)。 + public async ValueTask PushToAsync(DialerSession dialer, LineTaskSet set) => await PushAsync(dialer, set, CancellationToken.None); - /// Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。 - public async ValueTask DisconnectAsync(string probeId) + /// Backend 要求断开某个拨号方(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。 + public async ValueTask 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) { - probe.CloseReason = "disconnected"; + dialer.CloseReason = "disconnected"; await rpc.CloseAsync("disconnected", CancellationToken.None); disconnected = true; } @@ -255,23 +255,23 @@ public sealed class PeerHub return disconnected; } - /// 名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。 + /// 名单(期望状态里的 peers)里已经没有的拨号方:立刻关闭它的会话(吊销生效)。 public void CloseNotIn(IReadOnlySet 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) { - probe.CloseReason = "revoked"; - _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; } @@ -284,10 +284,10 @@ public sealed class PeerHub }; try { - Result result = await probe.Rpc.RequestAsync(FleetMethods.StateSync, parameters, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, ct); + Result 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) @@ -302,5 +302,5 @@ public sealed class PeerHub MoShiroMethods.Clock, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, - (context, _, _) => ValueTask.FromResult(Result.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() }))); + (context, _, _) => ValueTask.FromResult(Result.Ok(new ClockResult { ListenerMonoUs = context.Clock.NowUs() }))); } diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs b/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs index 5ee48b0..45a8b17 100644 --- a/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs +++ b/backend/agent/src/MoShiro.Agent/Tasks/TargetRunners.cs @@ -127,7 +127,7 @@ public sealed class TargetRunners return IPAddress.TryParse(target, out address!); } - /// 目标任务的行没有 Probe 一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。 + /// 目标任务的行没有拨号方一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。 private sealed class LocalSink : IFragmentSink { private readonly TargetRunners _owner; @@ -156,7 +156,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)); } diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs index 308d576..f2ceaa2 100644 --- a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs +++ b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs @@ -9,9 +9,9 @@ namespace MoShiro.Agent; /// /// 期望任务集(task_set)的调和器:每个任务先经过 (参数校验 + 本地硬上限)。 -/// 通过的线路任务为每个 Probe 会话各登记一组流(流编号和监听方积木都属于会话,互不干扰),并汇总成 line_task_set 下发给这个 Probe; -/// 通过的目标任务在本地运行,与 Probe 无关。被拒绝的任务带着原因(rejected[])回报给 Backend,其余任务照常运行。 -/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个 Probe 会话计算,会话总数由 PeerListenerOptions.MaxSessions 限制。 +/// 通过的线路任务为每个拨号方会话各登记一组流(流编号和监听方积木都属于会话,互不干扰),并汇总成 line_task_set 下发给这个拨号方; +/// 通过的目标任务在本地运行,与拨号方无关。被拒绝的任务带着原因(rejected[])回报给 Backend,其余任务照常运行。 +/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个拨号方会话计算,会话总数由 PeerListenerOptions.MaxSessions 限制。 /// public sealed class TaskManager { @@ -27,15 +27,17 @@ public sealed class TaskManager private readonly Func> _quicPorts; private readonly Func> _tcpPorts; private readonly TrustAnchor _trust; - private readonly Dictionary> _sessionLines = []; + private readonly bool _acceptLineTasks; + private readonly Dictionary> _sessionLines = []; private volatile Dictionary _peers = new(StringComparer.Ordinal); private Dictionary _lineSpecs = []; private long _version; private BudgetGuard? _guard; - public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> quicPorts, Func> tcpPorts, TrustAnchor? trust = null) + public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> quicPorts, Func> tcpPorts, TrustAnchor? trust = null, bool acceptLineTasks = true) { _trust = trust ?? TrustAnchor.None; + _acceptLineTasks = acceptLineTasks; _guardFactory = guardFactory; _streams = streams; _env = env; @@ -46,43 +48,43 @@ public sealed class TaskManager public BudgetGuard Guard => _guard ??= _guardFactory(); - /// 某个 Probe 的线路任务集变化(版本递增):由 推送给这个 Probe。 - public Action? OnLineTasksChanged { get; set; } + /// 某个拨号方的线路任务集变化(版本递增):由 推送给这个拨号方。 + public Action? OnLineTasksChanged { get; set; } - /// 允许连接的 Probe 变了(期望状态里的 peers):已经不在名单里的会话由 关闭。 + /// 允许连接的拨号方变了(期望状态里的 peers):已经不在名单里的会话由 关闭。 public Action>? OnPeersChanged { get; set; } - /// 点对点握手时查找 Probe 的公钥(SPKI)。 + /// 点对点握手时查找拨号方的公钥(SPKI)。 public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null; - /// 名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。 + /// 名册里全部拨号方的公钥:敲门关卡用它们派生配对密钥。 public IReadOnlyList PeerKeys() => [.. _peers.Values]; - /// 一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。 - public LineTaskSet OpenSession(ProbeSession probe) + /// 一个新的拨号方会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。 + public LineTaskSet OpenSession(DialerSession dialer) { lock (_gate) { var entries = new Dictionary(); 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); } } - /// Probe 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。 - public async Task CloseSessionAsync(ProbeSession probe) + /// 拨号方 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。 + public async Task CloseSessionAsync(DialerSession dialer) { List entries; lock (_gate) { - if (!_sessionLines.Remove(probe, out Dictionary? lines)) + if (!_sessionLines.Remove(dialer, out Dictionary? 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) { @@ -153,18 +155,18 @@ public sealed class TaskManager // 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断 var retired = new List(); - 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 current) in _sessionLines) + foreach ((DialerSession dialer, Dictionary current) in _sessionLines) { var next = new Dictionary(); 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; @@ -192,11 +194,11 @@ public sealed class TaskManager } // The Agent receives a full task snapshot for peer/trust changes and target-only tasks too. - // Those changes do not affect a Probe's line blocks, so avoid retransmitting the same full - // line_task_set to every Probe. New sessions still get their initial snapshot in OpenSession. + // 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((probe, SetFor(current))); + changes.Add((dialer, SetFor(current))); } } } @@ -207,15 +209,15 @@ public sealed class TaskManager } OnPeersChanged?.Invoke(new HashSet(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; } - /// 停止时释放所有 Probe 会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。 + /// 停止时释放所有拨号方会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。 public async Task StopAsync() { _targets.Reconcile([]); @@ -238,13 +240,12 @@ public sealed class TaskManager Version = _version, Tasks = [.. entries.Values.Select(e => e.Task)], QuicPorts = [.. _quicPorts()], - LegacyUdpPorts = [.. _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, @@ -254,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)); } /// 在数据面登记这个流并启动它的监听方积木。 diff --git a/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs b/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs index 4b7c01d..236d574 100644 --- a/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs +++ b/backend/agent/src/MoShiro.Agent/Tests/TestRunner.cs @@ -8,9 +8,9 @@ namespace MoShiro.Agent; /// /// 按需测试的编排(moshiro.test.run):Agent 是时间的主人。每个阶段: -/// 登记数据面的流 → moshiro.test.phase(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包) -/// → moshiro.test.phase_end(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → phase_done。 -/// 同一时间只能运行 max_concurrent_tests 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。 +/// 登记数据面的流 → moshiro.test.phase(拨号方 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待拨号方发包) +/// → moshiro.test.phase_end(拨号方 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → phase_done。 +/// 同一时间只能运行 max_concurrent_tests 个测试;拨号方 掉线或被取消时测试中止,已经上传的阶段数据保留。 /// public sealed class TestRunner { @@ -51,9 +51,9 @@ public sealed class TestRunner return Result.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.Fail(MoShiroErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent"); + return Result.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(); @@ -126,12 +126,12 @@ public sealed class TestRunner return false; } - /// 某个 Probe 掉线:它上面的测试中止,状态是 failed / probe_offline;别的 Probe 上的测试不受影响。 - public void ProbeLost(ProbeSession probe) + /// 某个拨号方掉线:它上面的测试中止,状态是 failed / dialer_offline;别的拨号方上的测试不受影响。 + 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 rpc = probe.Rpc ?? throw new TestFailure(MoShiroErrors.ProbeOffline.Value); + RpcSession rpc = dialer.Rpc ?? throw new TestFailure(MoShiroErrors.DialerOffline.Value); var start = new TestPhaseStartParams { TestId = test, @@ -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 } } - /// 一个正在运行的测试:它在哪个 Probe 上、怎样取消、是不是因为 Probe 掉线而中止。 + /// 一个正在运行的测试:它在哪个拨号方上、怎样取消、是不是因为拨号方掉线而中止。 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(); diff --git a/backend/probe/tests/MoShiro.Probe.Tests/ProbeReconnectBackoffTests.cs b/backend/agent/tests/MoShiro.Agent.Tests/ControlBackoffTests.cs similarity index 79% rename from backend/probe/tests/MoShiro.Probe.Tests/ProbeReconnectBackoffTests.cs rename to backend/agent/tests/MoShiro.Agent.Tests/ControlBackoffTests.cs index 5951e28..c8e639a 100644 --- a/backend/probe/tests/MoShiro.Probe.Tests/ProbeReconnectBackoffTests.cs +++ b/backend/agent/tests/MoShiro.Agent.Tests/ControlBackoffTests.cs @@ -1,13 +1,12 @@ -using MoShiro.Probe; -namespace MoShiro.Probe.Tests; +namespace MoShiro.Agent.Tests; -public sealed class ProbeReconnectBackoffTests +public sealed class ControlBackoffTests { [Fact] public void Next_UsesRequestedEscalationThenCapsAtOneMinute() { - var backoff = new ProbeReconnectBackoff(); + var backoff = new ControlBackoff(); TimeSpan[] expected = [ TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), @@ -23,7 +22,7 @@ public sealed class ProbeReconnectBackoffTests [Fact] public void Reset_RestartsAtOneSecond() { - var backoff = new ProbeReconnectBackoff(); + var backoff = new ControlBackoff(); _ = backoff.Next(); _ = backoff.Next(); diff --git a/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs b/backend/agent/tests/MoShiro.Agent.Tests/DialDirectoryTests.cs similarity index 52% rename from backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs rename to backend/agent/tests/MoShiro.Agent.Tests/DialDirectoryTests.cs index 8eadedb..69c0ae2 100644 --- a/backend/probe/tests/MoShiro.Probe.Tests/ProbeTests.cs +++ b/backend/agent/tests/MoShiro.Agent.Tests/DialDirectoryTests.cs @@ -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 { diff --git a/backend/agent/tests/MoShiro.Agent.Tests/LinkStateTests.cs b/backend/agent/tests/MoShiro.Agent.Tests/LinkStateTests.cs new file mode 100644 index 0000000..32b386d --- /dev/null +++ b/backend/agent/tests/MoShiro.Agent.Tests/LinkStateTests.cs @@ -0,0 +1,110 @@ +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)); + + 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 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> _items = new(); + + public IReadOnlyList> 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)); + } + } + } +} diff --git a/backend/probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj b/backend/agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj similarity index 77% rename from backend/probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj rename to backend/agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj index d27508f..d67924d 100644 --- a/backend/probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj +++ b/backend/agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj @@ -5,7 +5,7 @@ - + diff --git a/backend/probe/tests/MoShiro.Probe.Tests/OpenWrtUpdaterTests.cs b/backend/agent/tests/MoShiro.Agent.Tests/OpenWrtUpdaterTests.cs similarity index 98% rename from backend/probe/tests/MoShiro.Probe.Tests/OpenWrtUpdaterTests.cs rename to backend/agent/tests/MoShiro.Agent.Tests/OpenWrtUpdaterTests.cs index f9e3cc2..0dc5eb1 100644 --- a/backend/probe/tests/MoShiro.Probe.Tests/OpenWrtUpdaterTests.cs +++ b/backend/agent/tests/MoShiro.Agent.Tests/OpenWrtUpdaterTests.cs @@ -3,7 +3,7 @@ using Fleet.Core; using Fleet.Hosting; using MoShiro.Protocol; -namespace MoShiro.Probe.Tests; +namespace MoShiro.Agent.Tests; public sealed class OpenWrtUpdaterTests : IDisposable { diff --git a/backend/agent/tests/MoShiro.Agent.Tests/TaskLinkTests.cs b/backend/agent/tests/MoShiro.Agent.Tests/TaskLinkTests.cs new file mode 100644 index 0000000..8e268de --- /dev/null +++ b/backend/agent/tests/MoShiro.Agent.Tests/TaskLinkTests.cs @@ -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 condition, CancellationToken ct) + { + while (!condition()) + { + await Task.Delay(20, ct); + } + } +} diff --git a/backend/fleet/README.md b/backend/fleet/README.md index 6a1b52b..e4bc1b1 100644 --- a/backend/fleet/README.md +++ b/backend/fleet/README.md @@ -3,7 +3,7 @@ 「很多节点连到一个面板」这类产品都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置。Fleet 把这些做成与业务无关的库,MoShiro 是它的第一个使用者;产品只写业务。Fleet 不引用任何 `MoShiro.*`,也不出现产品的名字、端口和协议前缀,由 `ArchitectureTests` 在构建时检查。 ```text -┌──────────────────── 产品层(MoShiro:Protocol · Measurement · Agent · Probe · Backend)────────────────────┐ +┌──────────────────── 产品层(MoShiro:Protocol · Measurement · Agent · Backend)────────────────────┐ └────────────────────────────────────────────┬─────────────────────────────────────────────────────────────┘ │ 只依赖 Fleet ┌───────────────────────────── Fleet ────────┴─────────────────────────────────────────────────────────────┐ @@ -86,7 +86,7 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 ## 点对点会话 ```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:路由、二进制通道、发件箱 diff --git a/backend/fleet/src/Fleet.Core/NodeSupport.cs b/backend/fleet/src/Fleet.Core/NodeSupport.cs index 273494c..641322e 100644 --- a/backend/fleet/src/Fleet.Core/NodeSupport.cs +++ b/backend/fleet/src/Fleet.Core/NodeSupport.cs @@ -9,7 +9,7 @@ public enum NodeLogLevel Error, } -/// 节点日志:字段名统一为 logfmt(ts level msg agent probe session task result err)。逐包事件不记录,只累加计数。 +/// 节点日志:字段名统一为 logfmt(ts level msg agent dialer session task result err)。逐包事件不记录,只累加计数。 public interface INodeLog { void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields); diff --git a/backend/fleet/src/Fleet.Hosting/Hosting.cs b/backend/fleet/src/Fleet.Hosting/Hosting.cs index 5a3a03e..2c0a13d 100644 --- a/backend/fleet/src/Fleet.Hosting/Hosting.cs +++ b/backend/fleet/src/Fleet.Hosting/Hosting.cs @@ -287,7 +287,7 @@ public static class NodeConfig /// /// 常驻业务进程里唯一允许写文件的地方,而且只能写配置/身份目录(原子替换,类 Unix 上权限 0600)。 -/// 例如 Probe 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。 +/// 例如 拨号方 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。 /// public sealed class ConfigStore { diff --git a/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs b/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs index 157622a..32d616e 100644 --- a/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs +++ b/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs @@ -3,7 +3,7 @@ using Fleet.Core; namespace Fleet.Hosting; /// -/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。 +/// 节点自己要求有属主(passkey):拨号方和监听方共用这一份,启动时检查。 /// /// 属主是安装命令带来、注册时固定在 trust.json 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上, /// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。 diff --git a/backend/fleet/src/Fleet.Link/Peer.cs b/backend/fleet/src/Fleet.Link/Peer.cs index a039fb1..8f49c55 100644 --- a/backend/fleet/src/Fleet.Link/Peer.cs +++ b/backend/fleet/src/Fleet.Link/Peer.cs @@ -22,7 +22,7 @@ public sealed class PeerListenerOptions /// 按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。 public required Func LookupPeerKey { get; init; } - /// 预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。 + /// 预握手敲门:每个连接先过这一关(名册里的 拨号方 才算得出敲门包),不通过就直接断开,什么都不写。 public required KnockGate Knock { get; init; } public int MaxFrameBytes { get; init; } = 8 << 20; @@ -131,7 +131,7 @@ public sealed class PeerListener } phase = "control_session"; - _log.Info("peer session", ("probe", info.PeerId), ("result", "established"), ("remote", remote), + _log.Info("peer session", ("dialer", info.PeerId), ("result", "established"), ("remote", remote), ("elapsed_ms", (long)_time.GetElapsedTime(started).TotalMilliseconds)); await using (transport) { @@ -228,7 +228,7 @@ public sealed class PeerListener } } -/// 发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。 +/// 发起点对点会话(拨号方 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。 /// 点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着; /// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。 internal static class PeerSockets @@ -249,7 +249,7 @@ internal static class PeerSockets } } -/// 点对点会话的时间常数,Probe 和 Agent 两侧共用。 +/// 点对点会话的时间常数,拨号方和监听方两侧共用。 public static class PeerTimings { /// 建立会话(连接、敲门、TLS、挑战应答)的总时限。 @@ -263,7 +263,8 @@ public static class PeerConnector 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, INodeLog log, CancellationToken ct) + string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, INodeLog log, CancellationToken ct, + Action? onPhase = null) { // 证书按公钥的指纹固定;敲门密钥由双方身份密钥派生 byte[] expectedSpkiSha256 = NodeIdentity.Fingerprint(agentPublicKey); @@ -271,6 +272,7 @@ public static class PeerConnector client.NoDelay = true; PeerSockets.EnableKeepAlive(client.Client); string phase = "tcp_connect"; + onPhase?.Invoke(phase); long started = time.GetTimestamp(); try { @@ -280,15 +282,18 @@ public static class PeerConnector 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); @@ -326,9 +331,10 @@ public static class PeerConnector { 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", ex.GetType().Name), ("err", ex.Message), + ("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(); diff --git a/backend/fleet/src/Fleet.Link/Transports.cs b/backend/fleet/src/Fleet.Link/Transports.cs index 3426fb0..5ae0239 100644 --- a/backend/fleet/src/Fleet.Link/Transports.cs +++ b/backend/fleet/src/Fleet.Link/Transports.cs @@ -112,7 +112,7 @@ public sealed class WsTransport : IRpcTransport } /// -/// TLS 帧传输(Probe → Agent):u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文。 +/// TLS 帧传输(拨号方 → 监听方):u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文。 /// 比 WebSocket 轻得多,Agent 不需要内置 HTTP 服务端。 /// public sealed class TlsFrameTransport : IRpcTransport diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs index 168eb5a..4a37c5b 100644 --- a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs +++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs @@ -18,7 +18,7 @@ public static class FleetMethods /// /// 框架自己的方法声明,两个平面各一份。认证和握手阶段由连接代码直接处理的(Inline)不经过路由器。 -/// 同一个方法名在两个平面里可以有不同的发起方:fleet.state.sync 在节点面是服务端发给节点,在对等面是 Agent 发给 Probe。 +/// 同一个方法名在两个平面里可以有不同的发起方:fleet.state.sync 在节点面是服务端发给节点,在对等面是 监听方发给拨号方。 /// public static class FleetMethodTable { @@ -91,7 +91,7 @@ public sealed class HelloParams [JsonPropertyName("now_utc_ms")] public long NowUtcMs { get; set; } - /// 产品自定义的元数据(例如 MoShiro 的 tls_spki、probe_ports、local_limits 摘要)。 + /// 产品自定义的元数据(例如 MoShiro 的 tls_spki、listen 信息、local_limits 摘要)。 [JsonPropertyName("meta")] public JsonElement? Meta { get; set; } } diff --git a/backend/fleet/src/Fleet.Rpc/MethodTable.cs b/backend/fleet/src/Fleet.Rpc/MethodTable.cs index 71b9641..9b17369 100644 --- a/backend/fleet/src/Fleet.Rpc/MethodTable.cs +++ b/backend/fleet/src/Fleet.Rpc/MethodTable.cs @@ -1,6 +1,6 @@ namespace Fleet.Rpc; -/// 连接的哪一侧。节点面:服务端和节点;对等面:拨号方(Probe)和监听方(Agent)。 +/// 连接的哪一侧。节点面:服务端和节点;对等面:拨号方和监听方。 public enum RpcSide { Server, diff --git a/backend/fleet/src/Fleet.Rpc/PeerMethods.cs b/backend/fleet/src/Fleet.Rpc/PeerMethods.cs index 948f6d5..ca144ea 100644 --- a/backend/fleet/src/Fleet.Rpc/PeerMethods.cs +++ b/backend/fleet/src/Fleet.Rpc/PeerMethods.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; namespace Fleet.Rpc; /// -/// 节点间点对点会话的握手(Probe → Agent,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥, +/// 节点间点对点会话的握手(拨号方 → 监听方,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥, /// 经过 HKDF 派生出数据面使用的 session_id 与 session_key(所以不需要单独的“下发密钥”方法)。 /// public static class PeerMethods diff --git a/backend/fleet/src/Fleet.Rpc/RpcRouter.cs b/backend/fleet/src/Fleet.Rpc/RpcRouter.cs index ba4ffe3..ccad9cd 100644 --- a/backend/fleet/src/Fleet.Rpc/RpcRouter.cs +++ b/backend/fleet/src/Fleet.Rpc/RpcRouter.cs @@ -11,7 +11,7 @@ public readonly record struct RpcViolation(string Method, string Reason); public readonly record struct RpcDispatch(RpcMessage? Response, RpcViolation? Violation); /// -/// 方法路由,与传输方式无关,也是协议方向的唯一卡口:节点和服务端、Probe 和 Agent 的入站分发都经过这里。 +/// 方法路由,与传输方式无关,也是协议方向的唯一卡口:节点和服务端、拨号方和监听方 的入站分发都经过这里。 /// 由传输层提供(例如调用方节点)。路由器知道自己在连接的哪一侧()和声明表, /// 注册处理函数时核对方向、类型和角色,分发时拒绝没有声明或不该由对方发起的方法,发送时核对自己有没有资格发起。 /// diff --git a/backend/fleet/src/Fleet.Rpc/RpcSession.cs b/backend/fleet/src/Fleet.Rpc/RpcSession.cs index c9c0e78..af68c17 100644 --- a/backend/fleet/src/Fleet.Rpc/RpcSession.cs +++ b/backend/fleet/src/Fleet.Rpc/RpcSession.cs @@ -17,7 +17,7 @@ public readonly record struct RpcFrame(FrameKind Kind, ReadOnlyMemory Data /// /// 一条双向的、保序的消息通道:文本帧承载 JSON-RPC,二进制帧承载发件箱。 -/// 节点到服务端用 WebSocket,Probe 到 Agent 用 TLS 帧;会话引擎对两者一视同仁。 +/// 节点到服务端用 WebSocket,拨号方到监听方 用 TLS 帧;会话引擎对两者一视同仁。 /// public interface IRpcTransport : IAsyncDisposable { diff --git a/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs index 949566a..bca0653 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs @@ -5,7 +5,7 @@ public sealed class FleetServerOptions /// 节点 ID 的类型前缀,MoShiro 使用 agt。 public string NodeIdPrefix { get; set; } = "node"; - /// 加入令牌的「种类」对应的节点 ID 前缀(例如 probe → prb);没有列出的种类用 。 + /// 加入令牌的「种类」对应的节点 ID 前缀(例如 edge → edg);没有列出的种类用 。 public Dictionary NodeIdPrefixes { get; } = new(StringComparer.Ordinal); public int MaxFrameBytes { get; set; } = 16 << 20; diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs index 976df47..2f5bd95 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs @@ -69,7 +69,7 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier /// 处理一条 WebSocket 连接,直到连接结束。 public async Task RunConnectionAsync(WebSocket socket, string? remoteIp, CancellationToken ct) { - using var connection = new NodeConnection(this, socket, remoteIp); + await using var connection = new NodeConnection(this, socket, remoteIp); await connection.RunAsync(ct); } diff --git a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs index 6361887..4220999 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs @@ -29,10 +29,10 @@ public sealed class FleetServerBuilder internal Func? RoleOf => _roleOf; /// - /// 方法声明表(框架的加产品的,见 )和节点的角色解析(例如按节点 ID 前缀区分 Agent 和 Probe)。必须设置: + /// 方法声明表(框架的加产品的,见 )和可选的节点角色解析(声明表里限定了角色的方法才需要)。方法表必须设置: /// 路由器据此核对每个处理函数的方向、类型和角色,并拒绝没有声明或不该由节点发起的方法。 /// - public FleetServerBuilder WithMethods(MethodTable methods, Func roleOf) + public FleetServerBuilder WithMethods(MethodTable methods, Func? roleOf = null) { _methods = methods; _roleOf = roleOf; diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs index a05d4ca..03f8efb 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs @@ -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; } diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs index 9e867ea..c2f6349 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs @@ -11,7 +11,7 @@ namespace Fleet.Server; /// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。 /// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。 /// -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,6 +31,7 @@ internal sealed partial class NodeConnection : IDisposable private NodeRecord? _node; private NodeSessionInfo? _session; private string? _closeReason; + private bool _disposed; private bool _helloDone; private int _violations; @@ -137,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(); + } } } diff --git a/backend/fleet/tests/Fleet.Tests/KnockTests.cs b/backend/fleet/tests/Fleet.Tests/KnockTests.cs index ef56807..ef7aa21 100644 --- a/backend/fleet/tests/Fleet.Tests/KnockTests.cs +++ b/backend/fleet/tests/Fleet.Tests/KnockTests.cs @@ -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 { probe.PublicKeySpki }; + var roster = new List { 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++) { diff --git a/backend/fleet/tests/Fleet.Tests/LinkConnectTimeoutTests.cs b/backend/fleet/tests/Fleet.Tests/LinkConnectTimeoutTests.cs index 8108095..3677fc2 100644 --- a/backend/fleet/tests/Fleet.Tests/LinkConnectTimeoutTests.cs +++ b/backend/fleet/tests/Fleet.Tests/LinkConnectTimeoutTests.cs @@ -36,7 +36,7 @@ public sealed class LinkConnectTimeoutTests { Server = new Uri($"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"), Identity = NodeIdentity.Generate(), - NodeId = new NodeId("prb_test"), + NodeId = new NodeId("dlr_test"), ConnectTimeout = TimeSpan.FromMilliseconds(500), Backoff = new Backoff(TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50)), }; @@ -79,7 +79,15 @@ public sealed class LinkConnectTimeoutTests finally { await stop.CancelAsync(); - await running; + 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) diff --git a/backend/fleet/tests/Fleet.Tests/LinkTests.cs b/backend/fleet/tests/Fleet.Tests/LinkTests.cs index 9d2e063..430e1ee 100644 --- a/backend/fleet/tests/Fleet.Tests/LinkTests.cs +++ b/backend/fleet/tests/Fleet.Tests/LinkTests.cs @@ -328,10 +328,10 @@ public sealed class LinkTests public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate); - /// Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。 + /// Agent 的 TLS 证书公钥:拨号方 用它固定证书、派生敲门密钥。 public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo(); - /// 名册:能敲开门的 Probe 公钥。 + /// 名册:能敲开门的 拨号方 公钥。 public List KnockPeers { get; } = []; public async ValueTask DisposeAsync() @@ -352,25 +352,25 @@ 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(); - 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; 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, probe, "prb_1", null, 1 << 20, TimeProvider.System, scoped, default); + (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); NodeLogBatch batch = logs.TakeBatch(32); @@ -380,62 +380,62 @@ public sealed class LinkTests } [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(async () => - await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, logs, 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.TakeBatch(32).Records, entry => entry.Message == "peer control handshake failed"); Assert.Equal("peer_auth", failure.Fields["phase"]); Assert.Equal("InvalidOperationException", failure.Fields["exception"]); InvalidOperationException forged = await Assert.ThrowsAsync(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(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(RpcSide.Listener, table).On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result.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(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(transport, new RpcRouter(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(transport, new RpcRouter(RpcSide.Dialer, table), "dialer", TimeProvider.System); Task run = client.RunAsync(default); Result result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default); Assert.Equal(42, result.Value.Value); @@ -446,9 +446,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 的连接:握手失败,监听继续 @@ -458,16 +458,16 @@ 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(); } [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 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭) @@ -495,8 +495,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(); } diff --git a/backend/fleet/tests/Fleet.Tests/MethodDirectionTests.cs b/backend/fleet/tests/Fleet.Tests/MethodDirectionTests.cs index d87c821..63ebd3f 100644 --- a/backend/fleet/tests/Fleet.Tests/MethodDirectionTests.cs +++ b/backend/fleet/tests/Fleet.Tests/MethodDirectionTests.cs @@ -34,10 +34,10 @@ public sealed class MethodDirectionTests // t.ping 声明成请求:不能按通知注册 Assert.Throws(() => server.OnNotify("t.ping", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask)); - // 节点这一侧:t.note 只给 agent 角色,probe 注册不了;t.push 是它该处理的 - var probe = new RpcRouter(RpcSide.Node, s_table, "probe"); - probe.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()); - Assert.Throws(() => new RpcRouter(RpcSide.Server, s_table, "probe").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask)); + // 节点这一侧:t.note 只给 agent 角色,dialer 注册不了;t.push 是它该处理的 + var dialer = new RpcRouter(RpcSide.Node, s_table, "dialer"); + dialer.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()); + Assert.Throws(() => new RpcRouter(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.Ok(new AddResult()))); } @@ -99,7 +99,7 @@ public sealed class MethodDirectionTests { RpcRouter 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(RpcSide.Node, s_table, "agent"); node.CheckSend("t.note", MethodKind.Notification, "agent"); - Assert.Throws(() => node.CheckSend("t.note", MethodKind.Notification, "probe")); + Assert.Throws(() => 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 在对等面由监听方发起,拨号方处理 diff --git a/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs b/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs index 8cb373b..f3c7fee 100644 --- a/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs +++ b/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs @@ -4,7 +4,7 @@ using Fleet.Identity; namespace Fleet.Tests; -/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。 +/// 节点自己要求有属主(passkey):拨号方和监听方共用这一份,没有属主就不运行。 public sealed class OwnerGuardTests : IDisposable { private readonly TestAuthenticator _alice = new(); diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs index e563fb2..bbb94e0 100644 --- a/backend/fleet/tools/fleet-enroll/Program.cs +++ b/backend/fleet/tools/fleet-enroll/Program.cs @@ -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(); diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 54a8f74..f692602 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -815,7 +815,7 @@ "tags": [ "public" ], - "summary": "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", + "summary": "Latest host resource usage of every visible node (no hardware model, no addresses)", "operationId": "hosts_list", "responses": { "200": { @@ -846,7 +846,7 @@ "tags": [ "public" ], - "summary": "Host resource usage history of one agent or probe (columnar, averaged per step)", + "summary": "Host resource usage history of one node (columnar, averaged per step)", "operationId": "hosts_history", "parameters": [ { @@ -914,7 +914,7 @@ "tags": [ "public-nodes" ], - "summary": "Display info of the visible agents and probes: system, architecture, country and ISP (never an address)", + "summary": "Display info of the visible nodes: system, architecture, country and ISP (never an address)", "operationId": "nodes_list", "responses": { "200": { @@ -1640,7 +1640,7 @@ "tags": [ "admin-agents" ], - "summary": "All agents including pending and disabled ones", + "summary": "All nodes (agents) including pending and disabled ones; each says whether it listens, which dialers are connected to it and which listeners it dials", "operationId": "agents_list", "responses": { "200": { @@ -1742,7 +1742,7 @@ "tags": [ "admin-agents" ], - "summary": "Update name, labels, public host or visibility (merge-patch, If-Match supported)", + "summary": "Update name, labels, public host, ISP or visibility (merge-patch, If-Match supported)", "operationId": "agents_update", "parameters": [ { @@ -1897,7 +1897,7 @@ "tags": [ "admin-nodes" ], - "summary": "Recent Agent or Probe runtime logs", + "summary": "Recent runtime logs of a node", "operationId": "node_runtime_logs", "parameters": [ { @@ -2279,7 +2279,7 @@ "tags": [ "admin-join-tokens" ], - "summary": "Create a join token for an agent or a probe (role); the plaintext is returned only once", + "summary": "Create a join token for an agent; the plaintext is returned only once", "operationId": "join_tokens_create", "requestBody": { "content": { @@ -2393,317 +2393,13 @@ "x-moshiro-capability": "fleet.enroll" } }, - "/api/admin/probes": { - "get": { - "tags": [ - "admin-probe" - ], - "summary": "All probes, with the agents each one is connected to", - "operationId": "probes_list", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PageDtoOfProbeDto" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - } - }, - "x-moshiro-capability": "fleet.read" - } - }, - "/api/admin/probes/{id}": { - "get": { - "tags": [ - "admin-probe" - ], - "summary": "One probe", - "operationId": "probes_get", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProbeDto" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - } - }, - "x-moshiro-capability": "fleet.read" - }, - "patch": { - "tags": [ - "admin-probe" - ], - "summary": "Rename a probe", - "operationId": "probes_update", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchProbeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProbeDto" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - } - }, - "x-moshiro-capability": "fleet.write" - }, - "delete": { - "tags": [ - "admin-probe" - ], - "summary": "Delete a probe and its lines; stored data expires with the retention policy", - "operationId": "probes_delete", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "No Content", - "content": { - "application/json": { - "schema": { } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - } - }, - "x-moshiro-capability": "fleet.revoke" - } - }, - "/api/admin/probes/{id}/revoke": { - "post": { - "tags": [ - "admin-probe" - ], - "summary": "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", - "operationId": "probes_revoke", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProbeDto" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - } - }, - "x-moshiro-capability": "fleet.revoke" - } - }, - "/api/admin/agents/{id}/probes/{probe_id}/disconnect": { + "/api/admin/agents/{id}/dialers/{dialer_id}/disconnect": { "post": { "tags": [ "admin-agents" ], - "summary": "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", - "operationId": "agents_probe_disconnect", + "summary": "Make a listening node drop one dialer's session (the dialer reconnects unless it is disabled)", + "operationId": "agents_dialer_disconnect", "parameters": [ { "name": "id", @@ -2714,7 +2410,7 @@ } }, { - "name": "probe_id", + "name": "dialer_id", "in": "path", "required": true, "schema": { @@ -4327,7 +4023,7 @@ "tags": [ "admin-build" ], - "summary": "Version, tag, commit, build time and update state of the backend and every agent and probe", + "summary": "Version, tag, commit, build time and update state of the backend and every node", "operationId": "build_info_get", "responses": { "200": { @@ -4411,7 +4107,7 @@ "tags": [ "admin-build" ], - "summary": "Ask an online agent or probe to install the release it has been offered", + "summary": "Ask an online node to install the release it has been offered", "operationId": "nodes_update", "parameters": [ { @@ -4758,7 +4454,7 @@ "tags": [ "admin-links" ], - "summary": "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", + "summary": "Every possible dialer/listener pair: whether it is linked, the manual override and whether it is connected; plus the nodes and which of them listen", "operationId": "links_list", "responses": { "200": { @@ -4795,16 +4491,16 @@ "x-moshiro-capability": "fleet.read" } }, - "/api/admin/links/{probe_id}/{agent_id}": { + "/api/admin/links/{dialer_id}/{listener_id}": { "put": { "tags": [ "admin-links" ], - "summary": "Force a link on or off, or give it back to the automatic setting (enabled: null)", + "summary": "Connect a node to a listening node (enabled: true), force the link off (false), or give it back to the automatic setting (null)", "operationId": "links_set", "parameters": [ { - "name": "probe_id", + "name": "dialer_id", "in": "path", "required": true, "schema": { @@ -4812,7 +4508,7 @@ } }, { - "name": "agent_id", + "name": "listener_id", "in": "path", "required": true, "schema": { @@ -4880,21 +4576,41 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } } }, "x-moshiro-capability": "fleet.write" } }, - "/api/admin/links/{probe_id}/{agent_id}/retry": { + "/api/admin/links/{dialer_id}/{listener_id}/retry": { "post": { "tags": [ "admin-links" ], - "summary": "Enable and immediately retry the Probe-to-Agent connection", + "summary": "Enable and immediately retry the dialer-to-listener connection", "operationId": "links_retry", "parameters": [ { - "name": "probe_id", + "name": "dialer_id", "in": "path", "required": true, "schema": { @@ -4902,7 +4618,7 @@ } }, { - "name": "agent_id", + "name": "listener_id", "in": "path", "required": true, "schema": { @@ -4950,6 +4666,26 @@ } } } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } } }, "x-moshiro-capability": "fleet.write" @@ -5025,7 +4761,7 @@ "tags": [ "ops" ], - "summary": "The static native OpenWrt Probe installer and updater", + "summary": "The static native OpenWrt installer and updater (Agent)", "operationId": "install_openwrt", "responses": { "200": { @@ -5053,7 +4789,7 @@ "tags": [ "ops" ], - "summary": "The signed release manifest of the component an install code is for (exact signed bytes)", + "summary": "The signed release manifest an install code may fetch (exact signed bytes)", "operationId": "install_manifest", "parameters": [ { @@ -5119,7 +4855,7 @@ "tags": [ "ops" ], - "summary": "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", + "summary": "The static compose file of the agent or the backend (agent.yaml, backend.yaml; same bytes for everyone)", "operationId": "install_compose", "parameters": [ { @@ -5153,7 +4889,7 @@ "tags": [ "ops" ], - "summary": "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)", + "summary": "The static Podman Quadlet unit of the agent (agent.container; same bytes for everyone)", "operationId": "install_quadlet", "parameters": [ { @@ -5316,13 +5052,14 @@ "name", "status", "online", - "probe_connected", - "probes", + "listens", + "dialer_connected", + "dialers", + "outgoing", "host", "public_host", - "probe_tcp_port", - "probe_udp_ports", - "probe_quic_ports", + "tcp_port", + "quic_ports", "labels", "public_visible", "kinds", @@ -5351,13 +5088,22 @@ "online": { "type": "boolean" }, - "probe_connected": { + "listens": { "type": "boolean" }, - "probes": { + "dialer_connected": { + "type": "boolean" + }, + "dialers": { "type": "array", "items": { - "$ref": "#/components/schemas/ConnectedProbeDto" + "$ref": "#/components/schemas/ConnectedDialerDto" + } + }, + "outgoing": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OutgoingConnectionDto" } }, "host": { @@ -5373,7 +5119,7 @@ "public_host": { "type": "string" }, - "probe_tcp_port": { + "tcp_port": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": [ "integer", @@ -5381,18 +5127,7 @@ ], "format": "int32" }, - "probe_udp_ports": { - "type": "array", - "items": { - "pattern": "^-?(?:0|[1-9]\\d*)$", - "type": [ - "integer", - "string" - ], - "format": "int32" - } - }, - "probe_quic_ports": { + "quic_ports": { "type": "array", "items": { "pattern": "^-?(?:0|[1-9]\\d*)$", @@ -5593,6 +5328,12 @@ "null", "boolean" ] + }, + "isp": { + "type": [ + "null", + "string" + ] } } }, @@ -6075,16 +5816,16 @@ } } }, - "ConnectedProbeDto": { + "ConnectedDialerDto": { "required": [ - "probe_id", + "dialer_id", "version", "remote", "connected_since" ], "type": "object", "properties": { - "probe_id": { + "dialer_id": { "type": "string" }, "version": { @@ -6787,7 +6528,6 @@ "HostItemDto": { "required": [ "id", - "kind", "name", "online", "stale", @@ -6799,9 +6539,6 @@ "id": { "type": "string" }, - "kind": { - "type": "string" - }, "name": { "type": "string" }, @@ -7016,10 +6753,6 @@ "created_at": { "format": "date-time" }, - "role": { - "type": "string", - "default": "agent" - }, "created_by": { "type": [ "null", @@ -7077,12 +6810,12 @@ "required": [ "id", "agent_id", - "probe_id", - "probe_name", + "dialer_id", + "dialer_name", "name", "labels", "online", - "probe_connected", + "dialer_connected", "data_since", "kinds", "last_test" @@ -7095,10 +6828,10 @@ "agent_id": { "type": "string" }, - "probe_id": { + "dialer_id": { "type": "string" }, - "probe_name": { + "dialer_name": { "type": "string" }, "name": { @@ -7113,7 +6846,7 @@ "online": { "type": "boolean" }, - "probe_connected": { + "dialer_connected": { "type": "boolean" }, "data_since": { @@ -7157,28 +6890,28 @@ }, "LinkDto": { "required": [ - "probe_id", - "probe_name", - "agent_id", - "agent_name", + "dialer_id", + "dialer_name", + "listener_id", + "listener_name", "linked", "override", "connected", - "probe_runtime_status", - "agent_runtime_status" + "dialer_runtime_status", + "listener_runtime_status" ], "type": "object", "properties": { - "probe_id": { + "dialer_id": { "type": "string" }, - "probe_name": { + "dialer_name": { "type": "string" }, - "agent_id": { + "listener_id": { "type": "string" }, - "agent_name": { + "listener_name": { "type": "string" }, "linked": { @@ -7193,15 +6926,15 @@ "connected": { "type": "boolean" }, - "probe_runtime_status": { + "dialer_runtime_status": { "$ref": "#/components/schemas/NodeRuntimeStatus" }, - "agent_runtime_status": { + "listener_runtime_status": { "$ref": "#/components/schemas/NodeRuntimeStatus" }, "connection_state": { "type": "string", - "default": "connecting" + "default": "control_connecting" }, "connection_reason": { "type": [ @@ -7238,6 +6971,7 @@ "LinkMatrixDto": { "required": [ "auto_link", + "nodes", "items" ], "type": "object", @@ -7245,6 +6979,12 @@ "auto_link": { "type": "boolean" }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LinkNodeDto" + } + }, "items": { "type": "array", "items": { @@ -7253,6 +6993,33 @@ } } }, + "LinkNodeDto": { + "required": [ + "id", + "name", + "listens", + "online", + "runtime_status" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "listens": { + "type": "boolean" + }, + "online": { + "type": "boolean" + }, + "runtime_status": { + "$ref": "#/components/schemas/NodeRuntimeStatus" + } + } + }, "LinkRetryDto": { "required": [ "queued", @@ -7510,7 +7277,6 @@ "NodeBuildDto": { "required": [ "id", - "kind", "os", "os_name", "arch", @@ -7524,9 +7290,6 @@ "id": { "type": "string" }, - "kind": { - "type": "string" - }, "os": { "type": [ "null", @@ -7593,7 +7356,6 @@ "NodeInfoDto": { "required": [ "id", - "kind", "os", "os_name", "arch", @@ -7606,9 +7368,6 @@ "id": { "type": "string" }, - "kind": { - "type": "string" - }, "os": { "type": [ "null", @@ -7750,7 +7509,7 @@ "enum": [ "initializing", "running", - "probe_paused", + "idle", "maintenance", "stopping", "offline", @@ -7800,6 +7559,47 @@ } } }, + "OutgoingConnectionDto": { + "required": [ + "listener_id", + "listener_name", + "listener_online", + "version", + "remote", + "connected_since" + ], + "type": "object", + "properties": { + "listener_id": { + "type": "string" + }, + "listener_name": { + "type": "string" + }, + "listener_online": { + "type": "boolean" + }, + "version": { + "type": [ + "null", + "string" + ] + }, + "remote": { + "type": [ + "null", + "string" + ] + }, + "connected_since": { + "format": "date-time" + }, + "ip_version": { + "type": "string", + "default": "ipv4" + } + } + }, "OverviewDto": { "required": [ "range", @@ -7842,12 +7642,12 @@ "required": [ "id", "agent_id", - "probe_id", - "probe_name", + "dialer_id", + "dialer_name", "name", "labels", "online", - "probe_connected", + "dialer_connected", "endpoint_changes", "summary", "last_test" @@ -7860,10 +7660,10 @@ "agent_id": { "type": "string" }, - "probe_id": { + "dialer_id": { "type": "string" }, - "probe_name": { + "dialer_name": { "type": "string" }, "name": { @@ -7878,7 +7678,7 @@ "online": { "type": "boolean" }, - "probe_connected": { + "dialer_connected": { "type": "boolean" }, "endpoint_changes": { @@ -8212,27 +8012,6 @@ } } }, - "PageDtoOfProbeDto": { - "required": [ - "items", - "next_cursor" - ], - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProbeDto" - } - }, - "next_cursor": { - "type": [ - "null", - "string" - ] - } - } - }, "PageDtoOfRawChunkMetaDto": { "required": [ "items", @@ -8555,26 +8334,6 @@ } } }, - "PatchProbeRequest": { - "required": [ - "name" - ], - "type": "object", - "properties": { - "name": { - "type": [ - "null", - "string" - ] - }, - "isp": { - "type": [ - "null", - "string" - ] - } - } - }, "PatchTaskRequest": { "required": [ "params", @@ -8682,105 +8441,6 @@ } } }, - "ProbeConnectionDto": { - "required": [ - "agent_id", - "agent_name", - "agent_online", - "version", - "remote", - "connected_since" - ], - "type": "object", - "properties": { - "agent_id": { - "type": "string" - }, - "agent_name": { - "type": "string" - }, - "agent_online": { - "type": "boolean" - }, - "version": { - "type": [ - "null", - "string" - ] - }, - "remote": { - "type": [ - "null", - "string" - ] - }, - "connected_since": { - "format": "date-time" - }, - "ip_version": { - "type": "string", - "default": "ipv4" - } - } - }, - "ProbeDto": { - "required": [ - "id", - "name", - "created_at", - "revoked_at", - "revoked", - "online", - "connections", - "runtime_status" - ], - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "created_at": { - "format": "date-time" - }, - "revoked_at": { - "format": "date-time" - }, - "revoked": { - "type": "boolean" - }, - "online": { - "type": "boolean" - }, - "connections": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProbeConnectionDto" - } - }, - "runtime_status": { - "$ref": "#/components/schemas/NodeRuntimeStatus" - }, - "ipv6": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/Ipv6DiagnosticDto" - } - ] - }, - "added_by": { - "type": [ - "null", - "string" - ] - } - } - }, "ProblemDto": { "required": [ "type", @@ -9068,6 +8728,10 @@ "string" ], "format": "int64" + }, + "control_state": { + "type": "string", + "default": "dialing" } } }, @@ -9366,7 +9030,7 @@ "required": [ "line_id", "agent_id", - "probe_id", + "dialer_id", "from", "to", "step", @@ -9389,7 +9053,7 @@ "agent_id": { "type": "string" }, - "probe_id": { + "dialer_id": { "type": "string" }, "from": { @@ -9724,7 +9388,7 @@ "required": [ "id", "agent_id", - "probe_id", + "dialer_id", "plan", "phases", "status", @@ -9744,7 +9408,7 @@ "agent_id": { "type": "string" }, - "probe_id": { + "dialer_id": { "type": "string" }, "plan": { @@ -10109,9 +9773,6 @@ { "name": "admin-join-tokens" }, - { - "name": "admin-probe" - }, { "name": "admin-tasks" }, diff --git a/backend/probe/Directory.Build.props b/backend/probe/Directory.Build.props deleted file mode 100644 index 17e23af..0000000 --- a/backend/probe/Directory.Build.props +++ /dev/null @@ -1,21 +0,0 @@ - - - - Size - true - false - true - false - false - false - false - false - true - false - false - false - - - - - diff --git a/backend/probe/README.md b/backend/probe/README.md deleted file mode 100644 index b594278..0000000 --- a/backend/probe/README.md +++ /dev/null @@ -1,67 +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`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。 - -Agent 测量连接失败时,Probe 按 1、2、4、8、16 秒递增等待,再以 32 秒间隔重试 5 次,之后最多每 60 秒重试一次;连接恢复后退避重新从 1 秒开始。管理端把没有任何已启用 Agent 目标的 Probe 标为「暂停探测」,不会将链路断开误显示为已连接。 - -容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。 - -## 构建 - -需要 .NET SDK 10(版本见根目录 `global.json`)。从 `backend/` 目录构建: - -```sh -cd backend -dotnet build probe/src/MoShiro.Probe -c Release -``` - -发布 NativeAOT 程序需要对应平台的 C/C++ 工具链;`` 应与构建环境和目标平台匹配: - -```sh -dotnet publish probe/src/MoShiro.Probe -c Release -r -p:PublishAot=true -``` - -也可在仓库根目录运行 `bash scripts/publish.sh probe `,再使用 `scripts/package.sh probe ` 生成部署包。支持平台和安装流程见 [`../../docs/ops/deployment.md`](../../docs/ops/deployment.md)。 - -## 配置与运行 - -### OpenWrt 原生一键部署 - -管理端「添加 Probe」提供 OpenWrt · 原生二进制 命令,支持 ARM64/x86_64,以 root 执行即可注册并配置 procd。支持 dev/release 自动验签更新、失败回滚,不需要容器;详见 [部署文档](../../docs/ops/deployment.md#原生一键安装与自动更新)。 - -下面是手动注册方式;现代面板需同时传入面板提供的 `--genesis` 和 `--controllers`,否则默认属主检查会拒绝启动。 - -在管理端添加 Probe(生成 probe 角色的加入令牌),在目标机上注册(密钥由 Probe 自己生成): - -```sh -fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro --genesis <信任根指纹> --controllers <属主凭证ID> -``` - -写 `/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:`,自动选择 Linux amd64 或 arm64 镜像。容器默认读取 `/etc/moshiro/probe.json`,并在同一目录维护 `agents.json` 缓存,配置与缓存存放在命名 volume 中。建议使用宿主网络;当前独立 ICMP 目标任务由 Agent 执行。 - -## 设计说明 - -- Probe 只执行编译进程序的测量模块。它不落盘测量数据,也不能执行命令或自行更新(更新由宿主机上的更新器完成,Probe 只写清单和请求文件)。 -- 测量模块由 `backend/shared/MoShiro.Measurement/` 与 Agent 共用;Probe 负责发起连接、调度任务和记录本端数据。 -- 测量模型和协议细节见 [`../../docs/design/measurement.md`](../../docs/design/measurement.md) 与 [`../../docs/design/protocol.md`](../../docs/design/protocol.md);整体架构见 [`../../docs/design/architecture.md`](../../docs/design/architecture.md)。 - -Podman 系用 Quadlet(`packaging/quadlet/moshiro-probe.container`,默认自动更新),Docker 用 `packaging/compose/probe/compose.yaml`(手动更新);配置、身份和缓存放在命名 volume 中,无需宿主配置目录、keep-id 或手动 chown。首次导入和自动更新见部署文档。 diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs b/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs deleted file mode 100644 index 60867f9..0000000 --- a/backend/probe/src/MoShiro.Probe/Link/AgentConnection.cs +++ /dev/null @@ -1,688 +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; - -/// 所有连接共用的东西。 -public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log); - -/// -/// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 按 Agent 下发的 line_task_set 运行角色 → -/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。 -/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。 -/// -public sealed class AgentConnection : IDisposable -{ - private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds); - private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30); - - /// Agent 的任何帧(时钟回复、任务集)都算存活;比时钟同步间隔长得多,容得下差线路上的黑洞,但不会让一个 Agent 已经丢掉的会话永远挂着。 - private static readonly TimeSpan s_idleLimit = TimeSpan.FromSeconds(120); - - /// 一直在发测量报文、却这么久没有收到 Agent 的任何一个:数据面已经不通,重建会话。要比一次线路中断长(中断恢复后接着测),又不能拖到几个小时。 - private static readonly TimeSpan s_dataIdleLimit = TimeSpan.FromSeconds(90); - private static readonly RpcRouter s_router = BuildRouter(); - - /// 对等面入站方法的路由器(只读,测试用)。 - public static RpcRouter Router => s_router; - - private readonly ConnectionEnv _env; - private INodeLog _attemptLog; - private string _phase = "idle"; - private long _attemptStarted; - private volatile ConnectionSession? _session; - private int _previousQuicSourcePort; - private QuicDatagramTransport? _activeDatagrams; - private readonly object _handshakeGate = new(); - private readonly Queue _handshakeSamplesMs = new(); - private readonly SemaphoreSlim _retrySignal = new(0, 1); - private ProbeLinkAttemptState _attemptState = ProbeLinkAttemptState.Connecting; - private string? _lastError; - private DateTimeOffset? _retryAt; - private long _handshakeAttempts; - private long _handshakeSuccesses; - private long _handshakeFailures; - private long _handshakeTimeouts; - - public AgentConnection(KnownAgent endpoint, ConnectionEnv env) - { - Endpoint = endpoint; - _env = env; - _attemptLog = env.Log; - } - - public KnownAgent Endpoint { get; } - - 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; - - /// 管理端手动配对时,跳过当前退避等待,马上再拨一次。 - public void RetryNow() - { - if (IsConnected) - { - return; - } - try - { - _retrySignal.Release(); - } - catch (SemaphoreFullException) - { - // 已经有一次手动重试排队。 - } - catch (ObjectDisposedException) - { - // 对应线路正在被移除。 - } - } - - public void Dispose() => _retrySignal.Dispose(); - - public ProbeQuicLinkHealth QuicHealth() - { - lock (_handshakeGate) - { - double? p95 = null; - if (_handshakeSamplesMs.Count > 0) - { - double[] sorted = [.. _handshakeSamplesMs.Order()]; - p95 = sorted[(int)Math.Ceiling(sorted.Length * 0.95) - 1]; - } - - QuicDatagramTransport? datagrams = Volatile.Read(ref _activeDatagrams); - bool connected = _session is not null && datagrams?.IsConnected == true; - ProbeLinkAttemptState state = _attemptState; - string? lastError = _lastError; - if (!connected && state == ProbeLinkAttemptState.Connected) - { - state = ProbeLinkAttemptState.RetryWait; - lastError ??= "QUIC datagram transport is closed; reconnecting"; - } - - QuicTransportSnapshot? transport = datagrams?.GetTransportSnapshot(); - return new ProbeQuicLinkHealth - { - AgentId = Endpoint.AgentId, - IpVersion = Endpoint.IpVersion, - Connected = connected, - State = state, - LastError = lastError, - RetryAttempt = _lastRetryAttempt, - RetryInMs = state == ProbeLinkAttemptState.RetryWait && _retryAt is { } retryAt - ? Math.Max(0, (long)(retryAt - _env.Time.GetUtcNow()).TotalMilliseconds) - : null, - HandshakeAttempts = _handshakeAttempts, - HandshakeSuccesses = _handshakeSuccesses, - HandshakeFailures = _handshakeFailures, - HandshakeTimeouts = _handshakeTimeouts, - HandshakeP95Ms = p95, - HandshakeSampleCount = _handshakeSamplesMs.Count, - TransportPacketLossRate = transport?.PacketLossRate, - TransportPacketsSent = transport?.PacketsSent, - TransportCongestionEvents = transport?.CongestionEvents, - TransportEcnCongestionEvents = transport?.EcnCongestionEvents, - PathMtu = transport?.PathMtu, - }; - } - } - - private int _lastRetryAttempt; - - public async Task RunAsync(CancellationToken ct) - { - var backoff = new ProbeReconnectBackoff(); - while (!ct.IsCancellationRequested) - { - _attemptStarted = _env.Time.GetTimestamp(); - _attemptLog = new ContextNodeLog(_env.Log, ("agent", Endpoint.AgentId), ("ip_version", Endpoint.IpVersion), - ("attempt_id", Guid.NewGuid().ToString("N"))); - SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0); - try - { - string reason = await RunOnceAsync(backoff, ct); - SetAttempt(ProbeLinkAttemptState.RetryWait, reason, null, backoff.Attempts); - _attemptLog.Warn("agent session ended", ("phase", _phase), ("err", reason), - ("elapsed_ms", (long)_env.Time.GetElapsedTime(_attemptStarted).TotalMilliseconds)); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - return; - } - catch (Exception ex) - { - SetAttempt(ProbeLinkAttemptState.RetryWait, $"{_phase}: {ex.GetType().Name}: {ex.Message}", null, backoff.Attempts + 1); - _attemptLog.Error("agent connection attempt failed; retry will continue", ("phase", _phase), - ("exception", ex.GetType().FullName), ("err", ex.Message), - ("elapsed_ms", (long)_env.Time.GetElapsedTime(_attemptStarted).TotalMilliseconds), - ("socket_error", ex is SocketException socket ? socket.SocketErrorCode : null)); - } - - try - { - TimeSpan delay = backoff.Next(); - _attemptLog.Info("agent retry scheduled", ("retry_attempt", backoff.Attempts), ("retry_in_ms", (long)delay.TotalMilliseconds), - ("phase", _phase)); - lock (_handshakeGate) - { - _attemptState = ProbeLinkAttemptState.RetryWait; - _lastRetryAttempt = backoff.Attempts; - _retryAt = _env.Time.GetUtcNow() + 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; - } - } - } - - private async Task RunOnceAsync(ProbeReconnectBackoff backoff, CancellationToken ct) - { - _phase = "dns"; - _attemptLog.Info("agent connection attempt started", ("phase", _phase), ("host", Endpoint.Host), - ("tcp_port", Endpoint.TcpPort), ("quic_ports", string.Join(',', Endpoint.EffectiveQuicPorts))); - 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); - } - - _attemptLog.Info("agent address resolved", ("phase", _phase), ("remote", address), - ("elapsed_ms", (long)_env.Time.GetElapsedTime(_attemptStarted).TotalMilliseconds)); - _phase = "control_handshake"; - 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, _attemptLog, ct); - var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key); - var remote = (IPEndPoint)info.Remote!; - IReadOnlyList quicPorts = Endpoint.EffectiveQuicPorts; - int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0]; - var datagrams = new QuicDatagramTransport( - data, - _env.Clock, - new DataPlaneCounters(), - _attemptLog, - new IPEndPoint(remote.Address, quicPort), - agentKey, - Volatile.Read(ref _previousQuicSourcePort)); - Volatile.Write(ref _activeDatagrams, datagrams); - datagrams.Bind(); - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - - 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 = _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.MaxTestMbps, - }; - var session = new ConnectionSession(blockEnv, clock, outbox, _env); - var rpc = new RpcSession(transport, s_router, session, _env.Time); - rpc.OnBinary = (_, _) => ValueTask.CompletedTask; - - Task run = rpc.RunAsync(cts.Token); - Task receive = datagrams.RunAsync(cts.Token); - Task pump = PumpAsync(rpc, outbox, cts.Token); - try - { - // 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集 - _phase = "clock_sync"; - await SyncClockAsync(rpc, clock, rounds: 5, cts.Token); - _attemptLog.Info("agent clock synchronized", ("phase", _phase), ("offset_us", clock.OffsetUs)); - lock (_handshakeGate) - { - _handshakeAttempts++; - } - - try - { - SetAttempt(ProbeLinkAttemptState.Connecting, null, null, 0); - _phase = "quic_handshake"; - await datagrams.ConnectAsync(cts.Token); - lock (_handshakeGate) - { - _handshakeSuccesses++; - if (datagrams.HandshakeDurationMs is { } elapsed) - { - _handshakeSamplesMs.Enqueue(elapsed); - while (_handshakeSamplesMs.Count > 128) - { - _handshakeSamplesMs.Dequeue(); - } - } - } - } - catch (Exception ex) when (!(ex is OperationCanceledException && cts.IsCancellationRequested)) - { - lock (_handshakeGate) - { - _handshakeFailures++; - if (ex is TimeoutException) - { - _handshakeTimeouts++; - } - } - - throw; - } - if (datagrams.BoundPorts.Count > 0) - { - Volatile.Write(ref _previousQuicSourcePort, datagrams.BoundPorts[0]); - } - - backoff.Reset(); - _retrySignal.Wait(0, CancellationToken.None); // 若“立即重试”恰好赶在本次拨号成功前到达,本次成功已满足请求。 - _session = session; - _phase = "measuring"; - SetAttempt(ProbeLinkAttemptState.Connected, null, null, 0); - _attemptLog.Info("agent session up", ("phase", _phase), ("remote", remote), ("quic_port", quicPort), - ("local_udp_ports", string.Join(',', datagrams.BoundPorts)), ("handshake_ms", datagrams.HandshakeDurationMs), ("offset_us", clock.OffsetUs)); - Task maintenance = MaintainAsync(rpc, session, clock, datagrams, cts.Token); - Task finished = await Task.WhenAny(run, receive, pump, maintenance); - string reason = finished == run - ? "closed by the Agent control connection" - : finished == receive - ? "QUIC datagram transport closed" - : finished == pump - ? "Probe outbox pump stopped" - : "connection maintenance stopped"; - if (finished.IsFaulted && finished.Exception?.GetBaseException() is { } error) - { - reason = $"{error.GetType().Name}: {error.Message}"; - } - - await cts.CancelAsync(); - await BoundedAsync("stop tasks", () => Task.WhenAll(Quiet(run), Quiet(receive), Quiet(pump), Quiet(maintenance))); - return reason; - } - finally - { - _session = null; - Volatile.Write(ref _activeDatagrams, null); - await cts.CancelAsync(); - await BoundedAsync("stop blocks", session.StopAllAsync); - await BoundedAsync("wait control", () => Quiet(run)); - await BoundedAsync("wait datagrams", () => Quiet(receive)); - await BoundedAsync("wait pump", () => Quiet(pump)); - await BoundedAsync("dispose control", () => rpc.DisposeAsync().AsTask()); - await BoundedAsync("dispose quic", () => datagrams.DisposeAsync().AsTask()); - } - } - - private void SetAttempt(ProbeLinkAttemptState state, string? error, DateTimeOffset? retryAt, int attempt) - { - lock (_handshakeGate) - { - _attemptState = state; - if (error is not null || state == ProbeLinkAttemptState.Connected) - { - _lastError = error; - } - _retryAt = retryAt; - _lastRetryAttempt = attempt; - } - } - - private async Task BoundedAsync(string step, Func 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) - { - // 会话结束时的正常噪声 - } - } - - private static async Task PumpAsync(RpcSession 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 rpc, ClockEstimator clock, int rounds, CancellationToken ct) - { - for (int i = 0; i < rounds; i++) - { - long sent = _env.Clock.NowUs(); - Result 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()); - } - } - } - - /// 切块(每 10 秒)和时钟同步(每 30 秒)。 - private async Task MaintainAsync(RpcSession rpc, ConnectionSession session, ClockEstimator clock, QuicDatagramTransport datagrams, CancellationToken ct) - { - using var flush = new PeriodicTimer(s_flushInterval, _env.Time); - long lastClockUs = _env.Clock.NowUs(); - long seenIn = datagrams.Counters.PacketsIn; - long seenOut = datagrams.Counters.PacketsOut; - long inChangedUs = lastClockUs; - long lastReportUs = lastClockUs; - while (await flush.WaitForNextTickAsync(ct)) - { - if (rpc.IdleFor > s_idleLimit) - { - // Agent 已经不认这个会话(它重启、重连、或者空闲超时关了会话,而 FIN 没有到达):Probe 这边结束它,让外层按退避重拨 - _attemptLog.Warn("agent session is silent, reconnecting", ("phase", "control_session"), ("idle_s", (long)rpc.IdleFor.TotalSeconds)); - throw new IOException("the Agent stopped answering on this session"); - } - - // 数据面也要有回应:控制连接(TCP)还活着,QUIC 这条路却可能早已黑洞(一直在发、什么也收不到),表现为去程 100% 丢包、没有回程 - long packetsIn = datagrams.Counters.PacketsIn; - long packetsOut = datagrams.Counters.PacketsOut; - if (packetsIn != seenIn) - { - seenIn = packetsIn; - inChangedUs = _env.Clock.NowUs(); - } - else if (packetsOut != seenOut && _env.Clock.NowUs() - inChangedUs > s_dataIdleLimit.TotalMicroseconds) - { - _attemptLog.Warn("agent datagram path is silent, reconnecting", ("phase", "quic_data"), - ("idle_s", (_env.Clock.NowUs() - inChangedUs) / 1_000_000), ("packets_in", packetsIn), ("packets_out", packetsOut), - ("dropped_invalid", datagrams.Counters.DroppedInvalid), ("dropped_rate_limited", datagrams.Counters.DroppedRateLimited)); - throw new IOException("the Agent has not answered any measurement datagram"); - } - - seenOut = packetsOut; - if (_env.Clock.NowUs() - lastReportUs >= 60_000_000) - { - lastReportUs = _env.Clock.NowUs(); - QuicTransportSnapshot? snapshot = datagrams.GetTransportSnapshot(); - _attemptLog.Info("agent measurement summary", ("phase", "quic_data"), ("packets_in", packetsIn), ("packets_out", packetsOut), - ("dropped_invalid", datagrams.Counters.DroppedInvalid), ("dropped_rate_limited", datagrams.Counters.DroppedRateLimited), - ("control_idle_s", (long)rpc.IdleFor.TotalSeconds), ("roles", session.RoleCount), - ("transport_packets_sent", snapshot?.PacketsSent), ("path_mtu", snapshot?.PathMtu)); - } - session.FlushLineBlocks(); - if (_env.Clock.NowUs() - lastClockUs >= s_clockInterval.TotalMicroseconds) - { - lastClockUs = _env.Clock.NowUs(); - await SyncClockAsync(rpc, clock, rounds: 1, ct); - } - } - } - - private static RpcRouter BuildRouter() => - new RpcRouter(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.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed"); - } - - result.Rejected.AddRange(await session.ApplyAsync(set)); - } - - return Result.Ok(result); - }) - .On( - MoShiroMethods.TestPhase, - MoShiroJsonContext.Default.TestPhaseStartParams, - MoShiroJsonContext.Default.TestPhaseStartResult, - (session, start, _) => ValueTask.FromResult(Result.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) }))) - .On( - MoShiroMethods.TestPhaseEnd, - MoShiroJsonContext.Default.TestPhaseEndParams, - MoShiroJsonContext.Default.TestPhaseEndResult, - async (session, end, ct) => Result.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; - }); -} - -/// 一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。 -public sealed class ConnectionSession -{ - private const int MaxBlocks = 16; - - private readonly object _gate = new(); - private readonly Dictionary _lineBlocks = []; - private readonly ConcurrentDictionary _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; - } - } - } - - /// 调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。 - public async Task> ApplyAsync(LineTaskSet set) - { - var rejected = new List(); - var wanted = new Dictionary(); - 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(); - 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 blocks; - lock (_gate) - { - blocks = [.. _lineBlocks.Values]; - } - - foreach (RunningBlock running in blocks) - { - running.Block.Flush(FlushMode.Settled); - } - } - - /// 按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。 - 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; - } - - /// 阶段结束:收尾、交出列块,返回块数。 - public async Task 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 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); -} diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs deleted file mode 100644 index c2cdad2..0000000 --- a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Text.Json; -using Fleet.Core; -using Fleet.Hosting; -using MoShiro.Protocol; - -namespace MoShiro.Probe; - -/// -/// Probe 要连接的 Agent 列表:由面板作为期望状态(probe_agents)直接下发;面板连不上时用上一次保存的 agents.json。 -/// 列表变化时用 写回 agents.json(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。 -/// -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 _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)); - } - } - } - - /// 列表变化(调和连接)。 - public event Action? Changed; - - public long Version - { - get - { - lock (_gate) - { - return _version; - } - } - } - - public IReadOnlyList Agents - { - get - { - lock (_gate) - { - return [.. _agents]; - } - } - } - - /// 应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。 - public void Apply(ProbeAgentList result) - { - var next = new List(); - _trust.Update(result.TrustChain); - foreach (AgentEndpoint agent in result.Agents) - { - List ports = agent.QuicPorts.Count > 0 ? agent.QuicPorts : agent.LegacyUdpPorts ?? []; - if (!string.IsNullOrWhiteSpace(agent.Host)) - { - next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], PublicKey = agent.PublicKey }); - } - - if (!string.IsNullOrWhiteSpace(agent.Ipv6Host)) - { - next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, QuicPorts = [.. ports], 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 a, List 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.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey; -} diff --git a/backend/probe/src/MoShiro.Probe/MoShiro.Probe.csproj b/backend/probe/src/MoShiro.Probe/MoShiro.Probe.csproj deleted file mode 100644 index 20533a4..0000000 --- a/backend/probe/src/MoShiro.Probe/MoShiro.Probe.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - - Exe - moshiro-probe - MoShiro.Probe - true - true - true - full - true - true - false - - - - - - - - - - - diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs deleted file mode 100644 index 29ffc4b..0000000 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ /dev/null @@ -1,331 +0,0 @@ -using Fleet.Core; -using Fleet.Hosting; -using System.Collections.Concurrent; -using System.Text.Json; -using Fleet.Identity; -using Fleet.Link; -using Fleet.Rpc; -using MoShiro.Measurement; -using MoShiro.Protocol; - -namespace MoShiro.Probe; - -/// -/// Probe 的组装点:到面板的长连接(身份、Agent 列表、状态)+ 每个 Agent 一个测量连接(认证、重连、角色)。没有全局状态,测试可以在同一进程里启动多个。 -/// Agent 列表变化时只调和连接:新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。 -/// -public sealed class ProbeApp : IDisposable -{ - public const string Version = MoShiro.Protocol.BuildInfo.Version; - - private readonly ConnectionEnv _env; - private readonly Dictionary _connections = []; - private readonly ConcurrentDictionary _pendingRetries = new(StringComparer.Ordinal); - private readonly SemaphoreSlim _changed = new(0); - private readonly INodeLog _log; - private readonly INodeLogBatchSource? _logBatchSource; - private readonly FleetLink _link; - private readonly ReportingSection _reporting; - private readonly TimeProvider _time; - - /// 注册时由节点自己生成:Identity 用来连面板,PeerIdentity 是另一把独立的密钥,只用来连 Agent。 - public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null) - { - _log = log; - _logBatchSource = log as INodeLogBatchSource; - _time = time; - _reporting = file.Reporting; - var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log); - var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust); - _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log); - directory.Changed += () => _changed.Release(); - - RestartUpdater? update = RestartUpdater.FromEnvironment("probe", reason => restart?.RequestNow(reason), time, log); - OpenWrtUpdater? nativeUpdate = OpenWrtUpdater.FromEnvironment(time, log); - var options = new FleetLinkOptions - { - Server = new Uri(file.Fleet.Server), - Identity = identity.Identity, - ServerVerifier = trust, - NodeId = identity.NodeId, - NodeVersion = Version, - Capabilities = [MoShiroCapabilities.Base], - ShutdownReason = () => restart?.NowReason == "update" ? "updating" : "stopping", - HeartbeatExtra = () => - { - UpdateStatus? updateStatus = nativeUpdate?.Status() ?? update?.Status(); - NodeRuntimeStatus runtimeStatus = updateStatus?.State == "applying" ? NodeRuntimeStatus.Updating : _env.Directory.Agents.Count == 0 ? NodeRuntimeStatus.ProbePaused : NodeRuntimeStatus.Running; - NodeLogBatch logBatch = _logBatchSource?.TakeBatch(16) ?? new NodeLogBatch([], 0); - return JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra - { - RuntimeStatus = runtimeStatus, - Update = updateStatus, - Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")), - QuicLinks = SnapshotQuicLinks(), - RuntimeLogs = logBatch.Records.Count == 0 ? null : [.. logBatch.Records], - DroppedRuntimeLogs = logBatch.Dropped == 0 ? null : logBatch.Dropped, - }, MoShiroJsonContext.Default.ProbeHeartbeatExtra); - }, - HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta), - }; - var list = new StateHandler( - ProbeAgentList.StateType, - MoShiroJsonContext.Default.ProbeAgentList, - (version, doc, _) => - { - directory.Apply(doc); - return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version }); - }); - var router = new RpcRouter(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe) - .OnNotify(MoShiroMethods.ProbeLinkRetry, MoShiroJsonContext.Default.ProbeLinkRetryParams, (_, retry, _) => - { - RetryAgent(retry.AgentId.Value); - return ValueTask.CompletedTask; - }) - .OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) => - { - update?.Offer(offer); - nativeUpdate?.Offer(); - return ValueTask.CompletedTask; - }) - .OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) => - { - update?.RequestApply(); - nativeUpdate?.RequestApply(); - return ValueTask.CompletedTask; - }) - .OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) => - { - if (restart is not null && !restart.SetRemote(schedule.RestartAt)) - { - log.Warn("ignoring an invalid restart schedule from the panel"); - } - - return ValueTask.CompletedTask; - }); - _link = new FleetLink(options, router, [list], new Outbox(1 << 20), time, log); - } - - /// 到面板的连接(测试和状态查看用)。 - public FleetLink Link => _link; - - public AgentDirectory Directory => _env.Directory; - - /// 某个 Agent 的会话是否已经建立(测试和状态查看用)。 - public bool IsConnected(string agentId, string ipVersion = IpVersions.V4) - { - lock (_connections) - { - return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected; - } - } - - public AgentConnection? Connection(string agentId, string ipVersion = IpVersions.V4) - { - lock (_connections) - { - return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) ? running.Connection : null; - } - } - - private void RetryAgent(string agentId, bool queueIfMissing = true) - { - AgentConnection[] matches; - lock (_connections) - { - matches = [.. _connections.Values.Select(r => r.Connection).Where(c => c.Endpoint.AgentId == agentId)]; - } - - if (matches.Length == 0) - { - if (queueIfMissing) - { - _pendingRetries[agentId] = _time.GetUtcNow().AddSeconds(30).ToUnixTimeMilliseconds(); - } - _log.Warn("manual link retry requested but no matching Agent path is configured", ("agent", agentId)); - return; - } - - _pendingRetries.TryRemove(agentId, out _); - foreach (AgentConnection connection in matches) - { - connection.RetryNow(); - } - } - - private void RetryPending() - { - long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); - foreach ((string agentId, long expiresAt) in _pendingRetries.ToArray()) - { - if (expiresAt <= now) - { - _pendingRetries.TryRemove(agentId, out _); - } - else - { - RetryAgent(agentId, queueIfMissing: false); - } - } - } - - private List SnapshotQuicLinks() - { - AgentConnection[] connections; - lock (_connections) - { - connections = [.. _connections.Values.Select(r => r.Connection)]; - } - - return [.. connections.Select(c => c.QuicHealth())]; - } - - public async Task RunAsync(CancellationToken ct) - { - _log.Info("moshiro probe starting", MoShiro.Protocol.BuildInfo.Fields); - if (_env.Directory.Agents.Count == 0) - { - _log.Info("no agents known yet, waiting for the panel to send the list"); - } - - Task link = _link.RunAsync(ct); - Task host = ReportHostAsync(ct); - try - { - while (!ct.IsCancellationRequested) - { - await ReconcileAsync(_env.Directory.Agents, ct); - RetryPending(); - await _changed.WaitAsync(ct); - } - } - catch (OperationCanceledException) - { - // 停止 - } - finally - { - await ReconcileAsync([], CancellationToken.None); - await Task.WhenAny(Task.WhenAll(link, host), Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None)); - } - } - - /// 每隔几秒把主机资源占用的汇总数字发给面板,和 Agent 一样(没有型号、主机名、网卡名和 IP);链路断开时丢弃,重连之后下一次就补上。 - private async Task ReportHostAsync(CancellationToken ct) - { - if (!_reporting.HostStats) - { - return; - } - - var sampler = new HostSampler(_time); - sampler.Sample(); // 第一次只用来建立 CPU 和网速的基线 - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(_reporting.HostIntervalSeconds, 1, 60)), _time); - try - { - while (await timer.WaitForNextTickAsync(ct)) - { - if (_link.CurrentSession is { } session) - { - try - { - await session.NotifyAsync(MoShiroMethods.Host, sampler.Sample(), MoShiroJsonContext.Default.HostSnapshot, CancellationToken.None); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _log.Warn($"could not report host status: {ex.Message}"); - } - } - } - } - catch (OperationCanceledException) - { - // 停止 - } - } - - public void Dispose() - { - _env.Identity.Dispose(); - _changed.Dispose(); - } - - private async Task ReconcileAsync(IReadOnlyList agents, CancellationToken ct) - { - var wanted = agents.ToDictionary(a => a.PathKey); - var stopped = new List(); - lock (_connections) - { - foreach ((string id, Running running) in _connections.ToList()) - { - if (!wanted.TryGetValue(id, out KnownAgent? endpoint) || !AgentDirectory.Equal(endpoint, running.Connection.Endpoint)) - { - stopped.Add(running); - _connections.Remove(id); - } - } - } - - foreach (Running running in stopped) - { - await running.StopAsync(); - } - - if (ct.IsCancellationRequested) - { - return; - } - - lock (_connections) - { - foreach ((string id, KnownAgent endpoint) in wanted) - { - if (!_connections.ContainsKey(id)) - { - _connections[id] = Running.Start(new AgentConnection(endpoint, _env)); - } - } - } - } - - private sealed class Running : IDisposable - { - private readonly CancellationTokenSource _cts = new(); - private Task _run = Task.CompletedTask; - - private Running(AgentConnection connection) - { - Connection = connection; - } - - public AgentConnection Connection { get; } - - public static Running Start(AgentConnection connection) - { - var running = new Running(connection); - running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None); - return running; - } - - public async Task StopAsync() - { - await _cts.CancelAsync(); - try - { - await _run.WaitAsync(TimeSpan.FromSeconds(10)); - } - catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) - { - // 没有及时退出:不再等 - } - - Dispose(); - } - - public void Dispose() - { - Connection.Dispose(); - _cts.Dispose(); - } - } -} diff --git a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs deleted file mode 100644 index 73656f3..0000000 --- a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Text.Json.Serialization; -using MoShiro.Measurement; -using MoShiro.Protocol; - -namespace MoShiro.Probe; - -/// -/// probe.json:本地配置,和 Agent 一样由 fleet-enroll 注册之后写出身份目录(节点自己生成的两把密钥),这里只放面板地址和本地硬上限。 -/// limits 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。 -/// -public sealed class ProbeFile -{ - [JsonPropertyName("fleet")] - public ProbeFleetSection Fleet { get; set; } = new(); - - /// 每天定时重启一次,例如 04:00+08:00(不写偏移按 UTC);不写就不重启。环境变量 MOSHIRO_RESTART_AT 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。 - [JsonPropertyName("restart_at")] - public string? RestartAt { get; set; } - - [JsonPropertyName("limits")] - public ProbeLimits Limits { get; set; } = new(); - - /// 上报主机资源占用,和 Agent 一样(只有汇总数字,没有型号和 IP);默认开,设 host_stats: false 就一个字也不上报。 - [JsonPropertyName("reporting")] - public ReportingSection Reporting { get; set; } = new(); -} - -public sealed class ProbeFleetSection -{ - /// 面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。 - [JsonPropertyName("server")] - public string Server { get; set; } = ""; - - /// fleet-enroll 写出的身份目录:node.json、node.key(连面板的密钥)、tls.pfx(对等面的密钥)。 - [JsonPropertyName("identity_dir")] - public string IdentityDir { get; set; } = "/etc/moshiro/probe-id"; - - /// 节点自己要求有属主(passkey):没有就不运行。见 OwnerGuard(Agent 和 Probe 共用)。 - [JsonPropertyName("require_owner")] - public bool RequireOwner { get; set; } = true; -} - -public sealed class KnownAgent -{ - [JsonPropertyName("ip_version")] - public string IpVersion { get; set; } = IpVersions.V4; - - [JsonIgnore] - public string PathKey => AgentId + "~" + IpVersion; - - [JsonPropertyName("agent_id")] - public string AgentId { get; set; } = ""; - - [JsonPropertyName("host")] - public string Host { get; set; } = ""; - - [JsonPropertyName("tcp_port")] - public int TcpPort { get; set; } - - [JsonPropertyName("quic_ports")] - public List QuicPorts { get; set; } = [7443]; - - [JsonPropertyName("udp_ports")] - public List? LegacyUdpPorts { get; set; } - - [JsonIgnore] - public IReadOnlyList EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts; - - /// Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。 - [JsonPropertyName("public_key")] - public string PublicKey { get; set; } = ""; -} - -public sealed class ProbeLimits -{ - /// 按需测试里单个阶段允许的最大码率(Mbit/s)。 - [JsonPropertyName("max_test_mbps")] - public double MaxTestMbps { get; set; } = 100; - - /// 按需测试里单个阶段允许的最大包速率。 - [JsonPropertyName("max_total_pps")] - public long MaxTotalPps { get; set; } = 20_000; -} - -[JsonSerializable(typeof(ProbeFile))] -[JsonSerializable(typeof(AgentsCache))] -[JsonSerializable(typeof(OpenWrtHealth))] -public sealed partial class ProbeConfigContext : JsonSerializerContext; - -/// agents.json:最近一次从面板同步到的 Agent 列表(配置,不是测量数据);面板连不上时 Probe 靠它继续测量。 -public sealed class AgentsCache -{ - [JsonPropertyName("version")] - public long Version { get; set; } - - [JsonPropertyName("agents")] - public List Agents { get; set; } = []; -} diff --git a/backend/probe/src/MoShiro.Probe/Program.cs b/backend/probe/src/MoShiro.Probe/Program.cs deleted file mode 100644 index ac255a2..0000000 --- a/backend/probe/src/MoShiro.Probe/Program.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Fleet.Core; -using Fleet.Hosting; -using MoShiro.Probe; - -TimeProvider time = TimeProvider.System; -INodeLog log = new BufferedNodeLog(new ConsoleNodeLog(time), time); - -if (PlatformCheck.VerifyCrypto() is { } problem) -{ - log.Error("startup failed", ("err", problem)); - return 1; -} - -string configPath = "/etc/moshiro/probe.json"; -for (int i = 0; i < args.Length - 1; i++) -{ - if (args[i] == "--config") - { - configPath = args[i + 1]; - } -} - -ProbeFile file; -LoadedNodeIdentity identity; -RestartController restart; -try -{ - file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile); - restart = new RestartController(RestartSchedule.Resolve(file.RestartAt)); - if (!Uri.TryCreate(file.Fleet.Server, UriKind.Absolute, out _)) - { - throw new InvalidOperationException("probe.json needs fleet.server (the panel address)"); - } - - identity = LoadedNodeIdentity.Load(file.Fleet.IdentityDir); - if (!identity.NodeId.Value.StartsWith(MoShiro.Protocol.ProbeId.Prefix, StringComparison.Ordinal)) - { - identity.Dispose(); - throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token"); - } - - if (!OwnerGuard.Check(file.Fleet.IdentityDir, file.Fleet.RequireOwner, log)) - { - identity.Dispose(); - return 1; - } -} -catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException) -{ - log.Error("startup failed", ("err", ex.Message)); - return 1; -} - -using var restartScope = restart; -string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!; -using (identity) -{ - using var app = new ProbeApp(file, identity, directory, time, log, restart); - await ServiceShell.RunAsync(app.RunAsync, log, restart, time); -} - -return 0; diff --git a/backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs b/backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs index 385a547..3e4bf44 100644 --- a/backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs +++ b/backend/shared/MoShiro.Measurement/Blocks/BlockCatalog.cs @@ -3,7 +3,7 @@ using MoShiro.Protocol; namespace MoShiro.Measurement; /// -/// 一种采集方法(Kind)的两个角色:拨号方(Probe)和监听方(Agent),以及它能不能作为目标任务(Agent 自己探测一个地址,没有对端)运行。 +/// 一种采集方法(Kind)的两个角色:拨号方和监听方,以及它能不能作为目标任务(节点自己探测一个地址,没有对端)运行。 /// 积木只按机制命名、只产生原始列,不知道上层的方案或协议。 /// public sealed record BlockPair( @@ -19,7 +19,7 @@ public sealed record BlockPair( } /// -/// 积木目录:新增一种采集方法 = 在 里加一条声明,在这里加一行,实现它的两个角色。Probe 和 Agent 同时获得。 +/// 积木目录:新增一种采集方法 = 在 里加一条声明,在这里加一行,实现它的两个角色。所有节点同时获得。 /// hello 里上报的 capabilities、本地预算检查的支持列表都从这里推出来,不再手写。 /// public static class BlockCatalog @@ -37,9 +37,12 @@ public static class BlockCatalog public static BlockPair? Find(string kind) => All.FirstOrDefault(p => p.Kind == kind); - /// 这个构建的 Agent 能运行的 Kind(监听方,或目标任务)。 + /// 不监听的节点能运行的 Kind:只有目标任务(节点自己探测一个地址)。 + public static IReadOnlyList TargetKinds { get; } = [.. All.Where(p => p.RunsAsTarget).Select(p => p.Kind)]; + + /// 监听的节点能运行的 Kind(监听方,或目标任务)。 public static IReadOnlyList AgentKinds { get; } = [.. All.Where(p => p.Listener is not null || p.RunsAsTarget).Select(p => p.Kind)]; - /// 这个构建的 Probe 能运行的 Kind(有监听方可以配对的拨号方)。 - public static IReadOnlyList ProbeKinds { get; } = [.. All.Where(p => p.Listener is not null).Select(p => p.Kind)]; + /// 这个构建能作为拨号方运行的 Kind(有监听方可以配对的拨号方)。 + public static IReadOnlyList DialerKinds { get; } = [.. All.Where(p => p.Listener is not null).Select(p => p.Kind)]; } diff --git a/backend/shared/MoShiro.Measurement/Blocks/Bulk.cs b/backend/shared/MoShiro.Measurement/Blocks/Bulk.cs index cc08f70..c95ae77 100644 --- a/backend/shared/MoShiro.Measurement/Blocks/Bulk.cs +++ b/backend/shared/MoShiro.Measurement/Blocks/Bulk.cs @@ -56,8 +56,8 @@ internal static class BulkIo } /// -/// 批量传输,拨号方(Probe):开 streams 条 TCP 连接(可选 TLS),按 direction 发送(fwd)或接收(rev),持续 duration_s。 -/// 记录每个采样间隔每条连接的累计字节数(bytes 流,Probe 的时钟),Backend 据此求每个间隔的吞吐; +/// 批量传输,拨号方:开 streams 条 TCP 连接(可选 TLS),按 direction 发送(fwd)或接收(rev),持续 duration_s。 +/// 记录每个采样间隔每条连接的累计字节数(bytes 流,拨号方 的时钟),Backend 据此求每个间隔的吞吐; /// 同时采样第一条连接自己这一侧的内核 tcp_info(多条连接的累计计数器不能混在同一个序列里求差)。 /// 发送速率不超过本地上限 。 /// @@ -76,8 +76,8 @@ public sealed class TcpBulkDialer : BlockBase : base(env, spec) { long expectedRows = spec.Int("streams") * spec.Int("duration_s") * Math.Max(1, 1000 / Math.Max(1, spec.Int("sample_ms"))); - _log = new DriverLog(spec.Kind.FindStream("bytes")!, Side.Probe, (int)Math.Clamp(expectedRows + 256, 1024, 262_144)); - _info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Probe, env.Clock); + _log = new DriverLog(spec.Kind.FindStream("bytes")!, Side.Dialer, (int)Math.Clamp(expectedRows + 256, 1024, 262_144)); + _info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Dialer, env.Clock); _ts = _log.Store.ColumnIndex("ts"); _conn = _log.Store.ColumnIndex("conn"); _bytes = _log.Store.ColumnIndex("bytes"); @@ -211,7 +211,7 @@ public sealed class TcpBulkDialer : BlockBase } /// -/// 批量传输,监听方(Agent):接受连接,rev 时向 Probe 发送(受本地码率上限约束),fwd 时接收并丢弃; +/// 批量传输,监听方:接受连接,rev 时向 拨号方 发送(受本地码率上限约束),fwd 时接收并丢弃; /// 采样第一条连接自己这一侧的内核 tcp_info(Agent 的重传反映下行方向,代理场景里大流量走这个方向)。 /// 连接在 duration_s 之后(加几秒余量)或阶段结束时关闭。 /// @@ -224,7 +224,7 @@ public sealed class TcpBulkListener : BlockBase public TcpBulkListener(BlockEnv env, BlockSpec spec) : base(env, spec) { - _info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Agent, env.Clock); + _info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Listener, env.Clock); } public override RowStore? Records(string streamName) => streamName == "tcp_info" ? _info.Rows : null; diff --git a/backend/shared/MoShiro.Measurement/Blocks/Echo.cs b/backend/shared/MoShiro.Measurement/Blocks/Echo.cs index de07a7d..3627608 100644 --- a/backend/shared/MoShiro.Measurement/Blocks/Echo.cs +++ b/backend/shared/MoShiro.Measurement/Blocks/Echo.cs @@ -34,7 +34,7 @@ internal sealed class EchoRows _graceUs = graceUs; _settleOnReply = settleOnReply; StreamSpec spec = kind.FindStream("msg")!; - _log = new DriverLog(spec, Side.Probe, capacity); + _log = new DriverLog(spec, Side.Dialer, capacity); _size = _log.Store.ColumnIndex("size"); _t1 = _log.Store.ColumnIndex("t1"); _t4 = _log.Store.ColumnIndex("t4"); @@ -76,7 +76,7 @@ internal sealed class EchoRows } /// -/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。 +/// UDP 回显,拨号方:按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。 /// public sealed class DatagramEchoDialer : BlockBase { @@ -163,7 +163,7 @@ public sealed class DatagramEchoDialer : BlockBase } } -/// UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。 +/// UDP 回显,监听方:校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。 public sealed class DatagramEchoListener : BlockBase { private readonly RowStore _records; @@ -171,7 +171,7 @@ public sealed class DatagramEchoListener : BlockBase public DatagramEchoListener(BlockEnv env, BlockSpec spec) : base(env, spec) { - _records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768); + _records = new RowStore(spec.Kind.FindStream("msg")!, Side.Listener, 32768); } public override RowStore? Records(string streamName) => streamName == "msg" ? _records : null; @@ -216,7 +216,7 @@ public sealed class TcpEchoDialer : BlockBase : base(env, spec) { _rows = new EchoRows(spec.Kind, 16384, EchoRows.TcpGraceUs, settleOnReply: true); - _info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Probe, env.Clock); + _info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Dialer, env.Clock); } public override long Produced => _rows.Count; @@ -341,8 +341,8 @@ public sealed class TcpEchoListener : BlockBase public TcpEchoListener(BlockEnv env, BlockSpec spec, bool record = true) : base(env, spec) { - _records = record ? new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768) : null; - _info = record ? new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Agent, env.Clock) : null; + _records = record ? new RowStore(spec.Kind.FindStream("msg")!, Side.Listener, 32768) : null; + _info = record ? new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Listener, env.Clock) : null; } public override RowStore? Records(string streamName) => streamName switch diff --git a/backend/shared/MoShiro.Measurement/Blocks/Session.cs b/backend/shared/MoShiro.Measurement/Blocks/Session.cs index 8f113be..f68bc62 100644 --- a/backend/shared/MoShiro.Measurement/Blocks/Session.cs +++ b/backend/shared/MoShiro.Measurement/Blocks/Session.cs @@ -19,7 +19,7 @@ public sealed class IdleListener : BlockBase /// /// 反复建立连接(TCP,可选 TLS)并记录每个阶段的耗时和结果。结果编码与 Backend 的 ConnectOutcome 一致: -/// 0 成功、1 超时、2 被重置或拒绝、3 TLS 告警、4 其他。拨号方是 Probe 时对端是 Agent(按指纹固定证书,建好连接即关闭); +/// 0 成功、1 超时、2 被重置或拒绝、3 TLS 告警、4 其他。拨号方连接监听方时(按指纹固定证书,建好连接即关闭); /// 作为目标任务在 Agent 上运行时对端是任意地址(不校验证书,只测握手耗时)。 /// public sealed class ConnectDialer : BlockBase @@ -34,7 +34,7 @@ public sealed class ConnectDialer : BlockBase public ConnectDialer(BlockEnv env, BlockSpec spec) : base(env, spec) { - _log = new DriverLog(spec.Kind.FindStream("conn")!, Side.Probe, 1024); + _log = new DriverLog(spec.Kind.FindStream("conn")!, Side.Dialer, 1024); _attempt = _log.Store.ColumnIndex("attempt"); _ts = _log.Store.ColumnIndex("ts"); _tcp = _log.Store.ColumnIndex("tcp_us"); @@ -147,7 +147,7 @@ public sealed class HoldDialer : BlockBase public HoldDialer(BlockEnv env, BlockSpec spec) : base(env, spec) { - _log = new DriverLog(spec.Kind.FindStream("hold")!, Side.Probe, 1024); + _log = new DriverLog(spec.Kind.FindStream("hold")!, Side.Dialer, 1024); _ts = _log.Store.ColumnIndex("ts"); _event = _log.Store.ColumnIndex("event"); _lifetime = _log.Store.ColumnIndex("lifetime_ms"); @@ -293,7 +293,7 @@ public sealed class IcmpEchoSolo : BlockBase public IcmpEchoSolo(BlockEnv env, BlockSpec spec) : base(env, spec) { - _log = new DriverLog(spec.Kind.FindStream("msg")!, Side.Probe, 1024); + _log = new DriverLog(spec.Kind.FindStream("msg")!, Side.Dialer, 1024); _seq = _log.Store.ColumnIndex("seq"); _size = _log.Store.ColumnIndex("size"); _t1 = _log.Store.ColumnIndex("t1"); diff --git a/backend/shared/MoShiro.Measurement/Blocks/Train.cs b/backend/shared/MoShiro.Measurement/Blocks/Train.cs index 647ce09..cafd7da 100644 --- a/backend/shared/MoShiro.Measurement/Blocks/Train.cs +++ b/backend/shared/MoShiro.Measurement/Blocks/Train.cs @@ -7,7 +7,7 @@ namespace MoShiro.Measurement; /// /// 单向数据流(UDP train),监听方(Agent,行的主导方):按声明的码率均匀发包,发出的每个包 (seq, size, t_send) 记在按序号的记录里, -/// Probe 的接收时刻之后按序号合并(没有到达的就是丢失)。发送在专用线程上完成,不占用线程池。 +/// 拨号方 的接收时刻之后按序号合并(没有到达的就是丢失)。发送在专用线程上完成,不占用线程池。 /// 等不到对端的地址(5 秒内没有任何认证过的包)就以 结束。 /// public sealed class TrainListener : BlockBase @@ -21,7 +21,7 @@ public sealed class TrainListener : BlockBase : base(env, spec) { double pps = spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, spec.Int("size")); - _records = new RowStore(spec.Kind.FindStream("pkt")!, Side.Agent, (int)Math.Clamp(pps * spec.Int("duration_s") * 1.25, 1024, 4_000_000)); + _records = new RowStore(spec.Kind.FindStream("pkt")!, Side.Listener, (int)Math.Clamp(pps * spec.Int("duration_s") * 1.25, 1024, 4_000_000)); } public override long Produced => Interlocked.Read(ref _sent); @@ -30,7 +30,7 @@ public sealed class TrainListener : BlockBase public override async Task RunAsync(CancellationToken ct) { - // Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容 + // 拨号方 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容 Env.Datagrams!.Register(Spec.StreamId, static (in QuicDatagramDelivery _) => { }); try { @@ -138,7 +138,7 @@ public sealed class TrainListener : BlockBase } /// -/// 单向数据流,拨号方(Probe,行的跟随方):接收,记录每个序号第一次到达的时刻(按序号索引,热路径不分配); +/// 单向数据流,拨号方(行的跟随方):接收,记录每个序号第一次到达的时刻(按序号索引,热路径不分配); /// 序号超过本地包速率上限推算出的范围时忽略。阶段结束时按序号范围切成多个列块交给 Agent,每块不超过 的记录。 /// 开始时先发几个探活包,让 Agent 学到我们当前的源地址(NAT 映射)再开始发送。 /// @@ -159,9 +159,9 @@ public sealed class TrainDialer : BlockBase double pps = spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, spec.Int("size")); long seconds = Math.Max(1, spec.Int("duration_s")); _maxSeq = Math.Max(1024, Math.Min((long)(pps * seconds * 1.25), env.MaxPacketsPerSecond * seconds * 3 / 2)); - _received = new RowStore(spec.Kind.FindStream("pkt")!, Side.Probe, (int)_maxSeq); + _received = new RowStore(spec.Kind.FindStream("pkt")!, Side.Dialer, (int)_maxSeq); _tRecv = _received.ColumnIndex("t_recv"); - _step = new RowStore(spec.Kind.FindStream("step")!, Side.Probe, 16); + _step = new RowStore(spec.Kind.FindStream("step")!, Side.Dialer, 16); } public override async Task RunAsync(CancellationToken ct) diff --git a/backend/shared/MoShiro.Measurement/Core/Contracts.cs b/backend/shared/MoShiro.Measurement/Core/Contracts.cs index b8df778..177d503 100644 --- a/backend/shared/MoShiro.Measurement/Core/Contracts.cs +++ b/backend/shared/MoShiro.Measurement/Core/Contracts.cs @@ -51,7 +51,7 @@ public interface IDataSessions DataSession? Find(long sessionId); } -/// 只有一个会话(拨号方:Probe 与某个 Agent 的会话)。 +/// 只有一个会话(拨号方:与某个监听方的会话)。 public sealed class SingleSession : IDataSessions { private readonly DataSession _session; @@ -113,7 +113,7 @@ public sealed class DataPlaneCounters public sealed record FragmentScope(TaskId? Task, TestId? Test, int? Phase); /// -/// 积木交出一侧的行的地方。Probe:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。 +/// 积木交出一侧的行的地方。拨号方:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。 /// public interface IFragmentSink { @@ -139,7 +139,7 @@ public sealed class BlockFaultException : Exception } /// -/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方 = Probe,监听方 = Agent),写在同一个文件里, +/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方和监听方),写在同一个文件里, /// 共用包的构造、校验、行的记录;它们只产生原始列,不做任何统计,也不知道上层的方案或协议。 /// public interface IBlock @@ -192,7 +192,7 @@ public sealed class BlockSpec public string ConfigSignature => KindId + "|" + Port + "|" + string.Join(',', Params.Values.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText())); - /// 参数、端口或流编号变化时重启:Probe 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。 + /// 参数、端口或流编号变化时重启:拨号方 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。 public string Signature => StreamId + "|" + ConfigSignature; /// 校验参数(补全默认值)后构造;Kind 不存在或参数非法返回 null。 @@ -216,7 +216,7 @@ public sealed class BlockEnv public required INodeLog Log { get; init; } - /// 当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。 + /// 当前的数据面会话(拨号方固定;监听方是 Agent 当前的 拨号方 会话,可能没有)。 public required Func Session { get; init; } public QuicDatagramTransport? Datagrams { get; init; } @@ -249,7 +249,7 @@ public sealed class BlockEnv public TimeProvider Time => Clock.Time; - /// 同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 Probe 会话各起一组监听方积木)。 + /// 同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 拨号方 会话各起一组监听方积木)。 public BlockEnv WithSession(DataSession session) => new() { diff --git a/backend/shared/MoShiro.Measurement/Core/MonoClock.cs b/backend/shared/MoShiro.Measurement/Core/MonoClock.cs index a316445..f2e25da 100644 --- a/backend/shared/MoShiro.Measurement/Core/MonoClock.cs +++ b/backend/shared/MoShiro.Measurement/Core/MonoClock.cs @@ -1,6 +1,6 @@ namespace MoShiro.Measurement; -/// 节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;Probe 和 Agent 各自一个,块头里的时钟锚点把它对应到 UTC。 +/// 节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;每个节点各一个,块头里的时钟锚点把它对应到 UTC。 public sealed class MonoClock { private readonly TimeProvider _time; diff --git a/backend/shared/MoShiro.Measurement/Core/RowStore.cs b/backend/shared/MoShiro.Measurement/Core/RowStore.cs index dc8cb8d..c64fc56 100644 --- a/backend/shared/MoShiro.Measurement/Core/RowStore.cs +++ b/backend/shared/MoShiro.Measurement/Core/RowStore.cs @@ -4,8 +4,8 @@ namespace MoShiro.Measurement; /// /// 一侧记录的行:只含这一侧拥有的列(见 ),按连接键(通常是 seq)索引的环形缓冲, -/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 Probe 的列块到达后按键合并; -/// Probe 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。 +/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 拨号方 的列块到达后按键合并; +/// 拨号方 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。 /// public sealed class RowStore { @@ -65,7 +65,7 @@ public sealed class RowStore } /// - /// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:Probe 重启之后序号从 0 重新开始, + /// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:拨号方 重启之后序号从 0 重新开始, /// 旧会话里同一个序号的 t2/t3 还留着的话,会被当成新会话里这个包"上行已经到达",真正的上行丢失就被算成了下行丢失。 /// public void Clear() diff --git a/backend/shared/MoShiro.Measurement/Core/RunningBlock.cs b/backend/shared/MoShiro.Measurement/Core/RunningBlock.cs index 1ba7e7b..325e3d4 100644 --- a/backend/shared/MoShiro.Measurement/Core/RunningBlock.cs +++ b/backend/shared/MoShiro.Measurement/Core/RunningBlock.cs @@ -1,6 +1,6 @@ namespace MoShiro.Measurement; -/// 一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。 +/// 一个运行中的积木:自己的取消令牌和后台任务。拨号方和监听方共用。 public sealed class RunningBlock : IDisposable { private readonly CancellationTokenSource _cts = new(); diff --git a/backend/shared/MoShiro.Measurement/Merging/ChunkFinalizer.cs b/backend/shared/MoShiro.Measurement/Merging/ChunkFinalizer.cs index f4591c3..4ed9590 100644 --- a/backend/shared/MoShiro.Measurement/Merging/ChunkFinalizer.cs +++ b/backend/shared/MoShiro.Measurement/Merging/ChunkFinalizer.cs @@ -5,11 +5,11 @@ using MoShiro.Protocol; namespace MoShiro.Measurement; /// Agent 单调时钟(微秒)与 UTC(毫秒)的对应关系:块头里的时钟锚点。 -public readonly record struct ClockAnchor(long AgentMonoUs, long UtcMs); +public readonly record struct ClockAnchor(long ListenerMonoUs, long UtcMs); /// /// 给合并好的流盖章:生成完整的块头(任务/测试、参数快照、端口、时钟锚点、起止时间)。 -/// 起止时间由数据本身的时间戳换算得出,而不是取收到列块的时刻,所以不受 Probe 交付延迟的影响。 +/// 起止时间由数据本身的时间戳换算得出,而不是取收到列块的时刻,所以不受拨号方交付延迟的影响。 /// public static class ChunkFinalizer { @@ -26,7 +26,7 @@ public static class ChunkFinalizer ChunkId id) { RawChunkHeader source = fragment.Header; - long offsetUs = source.Clock.ProbeOffsetUs; + long offsetUs = source.Clock.DialerOffsetUs; long min = long.MaxValue; long max = long.MinValue; foreach (RawStream stream in streams) @@ -68,7 +68,7 @@ public static class ChunkFinalizer TestId = source.TestId, Phase = source.Phase, AgentId = agent, - ProbeId = source.ProbeId, + DialerId = source.DialerId, IpVersion = source.IpVersion, Target = null, Params = new Dictionary(resolvedParams), @@ -77,15 +77,15 @@ public static class ChunkFinalizer StartUtcMs = start, DurationMs = duration, Partial = false, - Clock = new ChunkClock { AgentMonoUs = anchor.AgentMonoUs, UtcMs = anchor.UtcMs, ProbeOffsetUs = offsetUs }, + Clock = new ChunkClock { ListenerMonoUs = anchor.ListenerMonoUs, UtcMs = anchor.UtcMs, DialerOffsetUs = offsetUs }, }; return new RawChunk(header, streams); } - public static long ToUtcMs(long value, ColumnRole role, ClockAnchor anchor, long probeOffsetUs) + public static long ToUtcMs(long value, ColumnRole role, ClockAnchor anchor, long dialerOffsetUs) { - long agentUs = role == ColumnRole.TsProbe ? value + probeOffsetUs : value; - long delta = agentUs - anchor.AgentMonoUs; + long listenerUs = role == ColumnRole.TsDialer ? value + dialerOffsetUs : value; + long delta = listenerUs - anchor.ListenerMonoUs; return anchor.UtcMs + (delta >= 0 ? delta / 1000 : -((-delta + 999) / 1000)); } } diff --git a/backend/shared/MoShiro.Measurement/Merging/FragmentMerger.cs b/backend/shared/MoShiro.Measurement/Merging/FragmentMerger.cs index 04653d0..cc6dd2f 100644 --- a/backend/shared/MoShiro.Measurement/Merging/FragmentMerger.cs +++ b/backend/shared/MoShiro.Measurement/Merging/FragmentMerger.cs @@ -11,10 +11,10 @@ public interface IRecordSource } /// -/// 把 Probe 一侧的列块(一个只带 Probe 列的 RawChunk)与 Agent 一侧的记录合并成完整的行。合并完全由 Kind 的 声明驱动: +/// 把 拨号方 一侧的列块(一个只带 拨号方 列的 RawChunk)与 Agent 一侧的记录合并成完整的行。合并完全由 Kind 的 声明驱动: /// -/// 声明了连接键、两侧都有列:按键连接。主导方是 Probe(回显)时行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失); -/// 主导方是 Agent(单向数据流)时行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失)。 +/// 声明了连接键、两侧都有列:按键连接。主导方是 拨号方(回显)时行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失); +/// 主导方是 Agent(单向数据流)时行取自 Agent 的记录(按列块头里的序号范围),拨号方 的列按键补上,缺失就是缺失(丢失)。 /// 没有连接键,或者只有主导方一侧有列:原样透传,只按声明的列顺序重排。 /// /// 合并之后的流按声明的列顺序排列,所以 Backend 的分析层不需要知道这些列是谁记录的;新增一种记录流不需要写合并代码。 @@ -41,7 +41,7 @@ public static class FragmentMerger streams.Add(value); } - // 两侧各自记录完整行的流:Probe 的列块里没有它(例如 Probe 在不支持内核采样的平台上)时,Agent 自己的行也要交出去 + // 两侧各自记录完整行的流:拨号方 的列块里没有它(例如 拨号方 在不支持内核采样的平台上)时,Agent 自己的行也要交出去 foreach (StreamSpec union in spec.Streams.Where(s => s.Union && fragment.Find(s.Name) is null)) { RawStream? own = records.Records(streamId, union.Name)?.Drain(includeKey: false); @@ -54,32 +54,32 @@ public static class FragmentMerger return Result>.Ok(streams); } - private static Result Join(StreamSpec spec, RawStream probe, RawChunkHeader header, RowStore? agent) + private static Result Join(StreamSpec spec, RawStream fragment, RawChunkHeader header, RowStore? local) { if (spec.Union) { - return Result.Ok(Concatenate(spec, probe, agent)); + return Result.Ok(Concatenate(spec, fragment, local)); } - IReadOnlyList probeColumns = spec.ColumnsOf(Side.Probe); - IReadOnlyList agentColumns = spec.ColumnsOf(Side.Agent); - if (spec.Key is null || (spec.Driver == Side.Probe && agentColumns.Count == 0)) + IReadOnlyList dialerColumns = spec.ColumnsOf(Side.Dialer); + IReadOnlyList listenerColumns = spec.ColumnsOf(Side.Listener); + if (spec.Key is null || (spec.Driver == Side.Dialer && listenerColumns.Count == 0)) { - return Result.Ok(Conform(spec, probe)); + return Result.Ok(Conform(spec, fragment)); } - RawColumn? key = probe.Find(spec.Key); - if (key is null || probeColumns.Any(c => probe.Find(c.Name) is null)) + RawColumn? key = fragment.Find(spec.Key); + if (key is null || dialerColumns.Any(c => fragment.Find(c.Name) is null)) { - return Result.Fail(MoShiroErrors.ValidationFailed, $"a fragment of stream {spec.Name} needs the columns {string.Join(", ", new[] { spec.Key }.Concat(probeColumns.Select(c => c.Name)))}"); + return Result.Fail(MoShiroErrors.ValidationFailed, $"a fragment of stream {spec.Name} needs the columns {string.Join(", ", new[] { spec.Key }.Concat(dialerColumns.Select(c => c.Name)))}"); } - return spec.Driver == Side.Probe ? JoinOnProbeRows(spec, probe, key, agent) : JoinOnAgentRows(spec, probe, key, header, agent); + return spec.Driver == Side.Dialer ? JoinOnDialerRows(spec, fragment, key, local) : JoinOnListenerRows(spec, fragment, key, header, local); } - private static Result JoinOnProbeRows(StreamSpec spec, RawStream probe, RawColumn key, RowStore? agent) + private static Result JoinOnDialerRows(StreamSpec spec, RawStream fragment, RawColumn key, RowStore? local) { - int n = probe.Count; + int n = fragment.Count; var columns = new List(spec.Columns.Count); foreach (ColumnSpec column in spec.Columns) { @@ -90,21 +90,21 @@ public static class FragmentMerger key.Values.AsSpan(0, n).CopyTo(values); Array.Fill(valid, true); } - else if (spec.OwnerOf(column) == Side.Probe) + else if (spec.OwnerOf(column) == Side.Dialer) { - RawColumn source = probe.Find(column.Name)!; + RawColumn source = fragment.Find(column.Name)!; for (int i = 0; i < n; i++) { values[i] = source.Values[i]; valid[i] = source.IsValid(i); } } - else if (agent is not null) + else if (local is not null) { - int index = agent.ColumnIndex(column.Name); + int index = local.ColumnIndex(column.Name); for (int i = 0; i < n; i++) { - valid[i] = agent.TryGet(key.Values[i], index, out values[i]); + valid[i] = local.TryGet(key.Values[i], index, out values[i]); } } @@ -114,17 +114,17 @@ public static class FragmentMerger return Result.Ok(new RawStream(spec.Name, n, columns)); } - private static Result JoinOnAgentRows(StreamSpec spec, RawStream probe, RawColumn key, RawChunkHeader header, RowStore? agent) + private static Result JoinOnListenerRows(StreamSpec spec, RawStream fragment, RawColumn key, RawChunkHeader header, RowStore? local) { if (!TryGetRange(header, out uint from, out uint to)) { return Result.Fail(MoShiroErrors.ValidationFailed, $"a fragment of stream {spec.Name} must declare its sequence range"); } - // 行取自 Agent 的记录:范围内发出过的每一个包都有一行,Probe 没有报告到达的就是丢失 - RawStream rows = agent?.Cut(from, to, includeKey: true) ?? new RawStream(spec.Name, 0, []); + // 行取自 Agent 的记录:范围内发出过的每一个包都有一行,拨号方 没有报告到达的就是丢失 + RawStream rows = local?.Cut(from, to, includeKey: true) ?? new RawStream(spec.Name, 0, []); var arrived = new Dictionary(); - for (int i = 0; i < probe.Count; i++) + for (int i = 0; i < fragment.Count; i++) { arrived.TryAdd(key.Values[i], i); // 重复的包只算第一次 } @@ -144,7 +144,7 @@ public static class FragmentMerger valid[i] = own.IsValid(i); } } - else if (probe.Find(column.Name) is { } source) + else if (fragment.Find(column.Name) is { } source) { RawColumn keys = rows.Find(spec.Key!)!; for (int i = 0; i < n; i++) @@ -164,13 +164,13 @@ public static class FragmentMerger } /// - /// 两侧各自记录完整行的流(例如各自套接字的内核采样):Probe 的行(列块里)加上 Agent 自己新记录的行,按声明的列顺序拼接。 + /// 两侧各自记录完整行的流(例如各自套接字的内核采样):拨号方 的行(列块里)加上 Agent 自己新记录的行,按声明的列顺序拼接。 /// 每一行里有一列标明它属于哪一侧,所以不需要连接。 /// - private static RawStream Concatenate(StreamSpec spec, RawStream probe, RowStore? agent) + private static RawStream Concatenate(StreamSpec spec, RawStream fragment, RowStore? local) { - RawStream own = Conform(spec, probe); - RawStream? extra = agent?.Drain(includeKey: false); + RawStream own = Conform(spec, fragment); + RawStream? extra = local?.Drain(includeKey: false); if (extra is null || extra.Count == 0) { return own; @@ -202,7 +202,7 @@ public static class FragmentMerger return new RawStream(spec.Name, n, columns); } - /// 按声明的列顺序重排;Probe 一侧已经是完整的行时不改变内容。 + /// 按声明的列顺序重排;拨号方 一侧已经是完整的行时不改变内容。 private static RawStream Conform(StreamSpec spec, RawStream stream) { var columns = new List(spec.Columns.Count); diff --git a/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs b/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs index 47a1218..45ef668 100644 --- a/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs +++ b/backend/shared/MoShiro.Measurement/Net/MsQuicConnection.cs @@ -75,7 +75,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection _configuration = configuration; _expectedPeerSpki = expectedPeerSpki; DatagramHandler = datagramHandler; - _log = new ContextNodeLog(log, ("quic_id", DiagnosticId), ("side", expectedPeerSpki is null ? "agent" : "probe")); + _log = new ContextNodeLog(log, ("quic_id", DiagnosticId), ("side", expectedPeerSpki is null ? "listener" : "dialer")); _closed = closed; _peerAddressChanged = peerAddressChanged; _self = GCHandle.Alloc(this); @@ -164,7 +164,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection long handshakeStarted = 0; try { - registration = MsQuicApi.RegistrationOpen("moshiro-probe"); + registration = MsQuicApi.RegistrationOpen("moshiro-dialer"); configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value); LoadClientCredentials(configuration); var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort); diff --git a/backend/shared/MoShiro.Measurement/Net/QuicAlpn.cs b/backend/shared/MoShiro.Measurement/Net/QuicAlpn.cs index 0786aad..2d0e8ef 100644 --- a/backend/shared/MoShiro.Measurement/Net/QuicAlpn.cs +++ b/backend/shared/MoShiro.Measurement/Net/QuicAlpn.cs @@ -1,6 +1,6 @@ namespace MoShiro.Measurement; -/// The application protocol negotiated by the Agent and Probe QUIC endpoints. +/// The application protocol negotiated by the Agent and 拨号方 QUIC endpoints. internal static class QuicAlpn { internal static readonly byte[] Value = "h3"u8.ToArray(); diff --git a/backend/shared/MoShiro.Measurement/Net/QuicDatagramTransport.cs b/backend/shared/MoShiro.Measurement/Net/QuicDatagramTransport.cs index 94e391b..66910dd 100644 --- a/backend/shared/MoShiro.Measurement/Net/QuicDatagramTransport.cs +++ b/backend/shared/MoShiro.Measurement/Net/QuicDatagramTransport.cs @@ -6,7 +6,7 @@ using Fleet.Core; namespace MoShiro.Measurement; /// -/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; Probe instances +/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; 拨号方 instances /// open one QUIC connection from a newly selected ephemeral source port. Measurement frames are sent /// only with MsQuic DATAGRAM calls; the existing TLS peer connection continues to carry session control. /// @@ -21,7 +21,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable private readonly DataSession? _clientSession; private readonly IPEndPoint? _remote; private readonly byte[]? _peerSpki; - private readonly int _previousSourcePort; + private int _previousSourcePort; private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously); private MsQuicListener? _listener; private MsQuicConnection? _client; @@ -38,8 +38,8 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable _serverCertificate = serverCertificate; } - /// Constructs a Probe-side QUIC dialer for one Agent session. - public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, int previousSourcePort) + /// Constructs a 拨号方-side QUIC dialer for one Agent session. + public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki) { _router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters, log); _log = log; @@ -49,7 +49,6 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable _clientSession = session; _remote = remote; _peerSpki = peerSpki; - _previousSourcePort = previousSourcePort; } public IReadOnlyList BoundPorts { get; private set; } = []; @@ -79,33 +78,39 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable BoundPorts = _listener.BoundPorts; } + /// + /// Opens a QUIC connection from a new ephemeral source port. May be called again after the previous connection closed; + /// the session, handler registrations and counters carry over. A failed attempt leaves the transport reusable. + /// public async Task ConnectAsync(CancellationToken ct) { if (_remote is null || _clientSession is null || _peerSpki is null) { - throw new InvalidOperationException("Only Probe-side QUIC transports can connect."); + throw new InvalidOperationException("Only 拨号方-side QUIC transports can connect."); } - try + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + await CloseClientAsync().ConfigureAwait(false); + int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort); + MsQuicConnection client = await MsQuicConnection.ConnectAsync(_remote.Address, _remote.Port, sourcePort, _peerSpki, _onDatagram, _log, ct).ConfigureAwait(false); + _client = client; + _clientSession.BindDatagramConnection(client); + BoundPorts = client.LocalEndPoint is { } local ? [local.Port] : [sourcePort]; + _previousSourcePort = BoundPorts[0]; + _clientReady.TrySetResult(); + } + + /// Completes when the current 拨号方-side connection has shut down (peer close, idle timeout, local close). + public Task WaitForDisconnectAsync(CancellationToken ct) => + _client is { } client ? client.WaitForShutdownAsync().WaitAsync(ct) : Task.CompletedTask; + + /// Closes the current 拨号方-side connection; the next opens a new one. + public async Task CloseClientAsync() + { + MsQuicConnection? client = Interlocked.Exchange(ref _client, null); + if (client is not null) { - int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort); - _client = await MsQuicConnection.ConnectAsync( - _remote.Address, - _remote.Port, - sourcePort, - _peerSpki, - _onDatagram, - _log, - ct); - _clientSession.BindDatagramConnection(_client); - BoundPorts = _client.LocalEndPoint is { } local ? [local.Port] : [sourcePort]; - _clientReady.TrySetResult(); - } - catch (Exception ex) - { - _log.Error("QUIC client connection failed", ("err", ex.Message)); - _clientReady.TrySetException(ex); - throw; + await client.DisposeAsync().ConfigureAwait(false); } } @@ -128,15 +133,9 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable return false; } + /// Agent side: holds the listener open until cancelled. 拨号方-side connections are driven by and . public async Task RunAsync(CancellationToken ct) { - if (_remote is not null) - { - await _clientReady.Task.WaitAsync(ct); - await _client!.WaitForShutdownAsync().WaitAsync(ct); - return; - } - try { await Task.Delay(Timeout.InfiniteTimeSpan, ct); @@ -171,10 +170,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable { try { - if (_client is not null) - { - await _client.DisposeAsync().ConfigureAwait(false); - } + await CloseClientAsync().ConfigureAwait(false); if (_listener is not null) { diff --git a/backend/shared/MoShiro.Measurement/Net/Tcp.cs b/backend/shared/MoShiro.Measurement/Net/Tcp.cs index 3141426..2344b21 100644 --- a/backend/shared/MoShiro.Measurement/Net/Tcp.cs +++ b/backend/shared/MoShiro.Measurement/Net/Tcp.cs @@ -223,7 +223,7 @@ public sealed class TcpAcceptor using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct); authCts.CancelAfter(s_authTimeout); - // 预握手敲门:名册里的 Probe 才算得出敲门包;不通过就不写任何字节、直接断开 + // 预握手敲门:名册里的 拨号方 才算得出敲门包;不通过就不写任何字节、直接断开 if (!await _knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, authCts.Token)) { _counters.DropInvalid(); diff --git a/backend/shared/MoShiro.Measurement/Net/TcpInfo.cs b/backend/shared/MoShiro.Measurement/Net/TcpInfo.cs index c82d61a..43161b7 100644 --- a/backend/shared/MoShiro.Measurement/Net/TcpInfo.cs +++ b/backend/shared/MoShiro.Measurement/Net/TcpInfo.cs @@ -71,7 +71,7 @@ public static class TcpInfo /// /// 一条连接上周期性的内核采样(tcp_info 流):每个采样一行 (ts, side, srtt, rttvar, cwnd, retrans_total, segs_out, bytes_acked, conn)。 -/// 两端各采样自己的套接字:Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。 +/// 两端各采样自己的套接字:拨号方 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。 /// 累计计数器在 Backend 按 (side, conn) 分别求差;conn 是当前记录器内分配的连接编号,不包含网络地址。 /// public sealed class TcpInfoRecorder @@ -84,7 +84,7 @@ public sealed class TcpInfoRecorder { Rows = new RowStore(spec, side, 4096); _clock = clock; - _side = side == Side.Probe ? 0 : 1; + _side = side == Side.Dialer ? 0 : 1; } public RowStore Rows { get; } diff --git a/backend/shared/MoShiro.Measurement/Platform/HostSampler.cs b/backend/shared/MoShiro.Measurement/Platform/HostSampler.cs index 7c30c04..304fb64 100644 --- a/backend/shared/MoShiro.Measurement/Platform/HostSampler.cs +++ b/backend/shared/MoShiro.Measurement/Platform/HostSampler.cs @@ -4,7 +4,7 @@ using MoShiro.Protocol; namespace MoShiro.Measurement; /// -/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 +/// 拨号方和监听方共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。 /// 只有汇总的数字:不采集 CPU 型号、主机名、网卡名和任何 IP 地址,所以面板和展示页想泄漏也泄漏不出去。 /// Linux 读 /proc 和 /sys;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。 /// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。 diff --git a/backend/shared/MoShiro.Measurement/Platform/ReportingSection.cs b/backend/shared/MoShiro.Measurement/Platform/ReportingSection.cs index 9ae461c..76b820c 100644 --- a/backend/shared/MoShiro.Measurement/Platform/ReportingSection.cs +++ b/backend/shared/MoShiro.Measurement/Platform/ReportingSection.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; namespace MoShiro.Measurement; -/// 节点本地配置里的 reporting 段(面板无法修改):Agent 和 Probe 共用。 +/// 节点本地配置里的 reporting 段(面板无法修改):拨号方和监听方共用。 public sealed class ReportingSection { /// 上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。 diff --git a/backend/shared/MoShiro.Protocol/Catalog/KindSpec.cs b/backend/shared/MoShiro.Protocol/Catalog/KindSpec.cs index 01059f9..1b9f7e1 100644 --- a/backend/shared/MoShiro.Protocol/Catalog/KindSpec.cs +++ b/backend/shared/MoShiro.Protocol/Catalog/KindSpec.cs @@ -23,37 +23,37 @@ public enum ColumnEnc Delta, } -/// 时间列的时钟归属:Probe、Agent 各自的单调时钟(微秒),或所在一侧的本地时钟。 +/// 时间列的时钟归属:拨号方、监听方各自的单调时钟(微秒),或所在一侧的本地时钟。 public enum ColumnRole { None, - TsProbe, - TsAgent, + TsDialer, + TsListener, TsLocal, } public sealed record ColumnSpec(string Name, ColumnType Type, ColumnEnc Enc = ColumnEnc.Plain, bool Nullable = false, ColumnRole Role = ColumnRole.None); -/// 数据面上的一侧:Probe 永远是拨号方,Agent 永远是监听方。 +/// 数据面上的一侧:拨号方发起连接,监听方接受连接。 public enum Side { - Probe, - Agent, + Dialer, + Listener, } /// /// 一个记录流(一张表)。 是行的主导方:行由它发出的包或事件决定,另一侧的列按 (例如 seq)补充, /// 缺失就是缺失(回显缺 t2/t3 表示上行丢失,单向数据流缺 t_recv 表示丢失)。没有 的流只有主导方一侧的列,原样透传。 -/// 每一列属于哪一侧由它的时钟决定(TsProbe 属于 Probe,TsAgent 属于 Agent),其他列属于主导方。 +/// 每一列属于哪一侧由它的时钟决定(TsDialer 属于拨号方,TsListener 属于监听方),其他列属于主导方。 /// 流是例外:两侧各自记录完整的行(行里有一列标明是哪一侧),合并时直接拼接,不连接。 -/// 这一条声明同时决定了两端各记录什么、Agent 怎么合并,新增一种记录流不需要写合并代码。 +/// 这一条声明同时决定了两端各记录什么、监听方怎么合并,新增一种记录流不需要写合并代码。 /// -public sealed record StreamSpec(string Name, IReadOnlyList Columns, Side Driver = Side.Probe, string? Key = null, bool Union = false) +public sealed record StreamSpec(string Name, IReadOnlyList Columns, Side Driver = Side.Dialer, string? Key = null, bool Union = false) { public Side OwnerOf(ColumnSpec column) => column.Role switch { - ColumnRole.TsProbe => Side.Probe, - ColumnRole.TsAgent => Side.Agent, + ColumnRole.TsDialer => Side.Dialer, + ColumnRole.TsListener => Side.Listener, _ => Driver, }; @@ -70,7 +70,7 @@ public sealed record BlockSet(string Transport, string Pacer, string Payload, st /// /// 采集方法声明:积木组合 + 参数 + 记录的列 + 带宽预算。 -/// 这一条声明同时被 Probe/Agent(组装执行)、Backend(校验与目录)、Analysis(按列名读数据)使用。 +/// 这一条声明同时被拨号方和监听方(组装执行)、Backend(校验与目录)、Analysis(按列名读数据)使用。 /// public sealed record KindSpec( string Id, @@ -109,9 +109,9 @@ public static class Col /// 单调递增的序号:差分编码。 public static ColumnSpec Seq(string name) => new(name, ColumnType.U32, ColumnEnc.Delta); - public static ColumnSpec TsProbe(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsProbe); + public static ColumnSpec TsDialer(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsDialer); - public static ColumnSpec TsAgent(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsAgent); + public static ColumnSpec TsListener(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsListener); public static ColumnSpec TsLocal(string name) => new(name, ColumnType.I64, ColumnEnc.Delta, false, ColumnRole.TsLocal); } diff --git a/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs b/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs index 4d1eb77..c0984f1 100644 --- a/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs +++ b/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs @@ -12,9 +12,9 @@ public static class Kinds "msg", [ Col.Seq("seq"), Col.U16("size"), - Col.TsProbe("t1"), Col.TsAgent("t2", nullable: true), Col.TsAgent("t3", nullable: true), Col.TsProbe("t4", nullable: true), + Col.TsDialer("t1"), Col.TsListener("t2", nullable: true), Col.TsListener("t3", nullable: true), Col.TsDialer("t4", nullable: true), ], - Side.Probe, + Side.Dialer, "seq"); private static readonly StreamSpec s_tcpInfo = new( @@ -43,7 +43,7 @@ public static class Kinds public static readonly KindSpec UdpTrain = new( "udp_train", "QUIC Datagram train", - "Fixed-rate or laddered one-way QUIC Datagram stream (Agent to Probe) for loss and delay variation under load.", + "Fixed-rate or laddered one-way QUIC Datagram stream (Agent to 拨号方) for loss and delay variation under load.", Scope.Line, new BlockSet("quic_datagram", "ladder", "fixed", "sink", []), [ @@ -54,8 +54,8 @@ public static class Kinds P.Int("port", 7443, 1, 65535), ], [ - new StreamSpec("pkt", [Col.Seq("seq"), Col.U16("size"), Col.TsAgent("t_send"), Col.TsProbe("t_recv", nullable: true)], Side.Agent, "seq"), - new StreamSpec("step", [Col.U16("index"), Col.U32("mbps"), Col.TsProbe("start_ts"), Col.TsProbe("end_ts")]), + new StreamSpec("pkt", [Col.Seq("seq"), Col.U16("size"), Col.TsListener("t_send"), Col.TsDialer("t_recv", nullable: true)], Side.Listener, "seq"), + new StreamSpec("step", [Col.U16("index"), Col.U32("mbps"), Col.TsDialer("start_ts"), Col.TsDialer("end_ts")]), ], p => (long)(p.GetNumber("mbps") * 1_000_000)); @@ -91,7 +91,7 @@ public static class Kinds P.Bool("tls", true), ], [ - new StreamSpec("bytes", [Col.TsProbe("ts"), Col.U8("conn"), Col.U64("bytes")]), + new StreamSpec("bytes", [Col.TsDialer("ts"), Col.U8("conn"), Col.U64("bytes")]), s_tcpInfo, ], _ => 0); @@ -111,7 +111,7 @@ public static class Kinds [ new StreamSpec( "conn", - [Col.Seq("attempt"), Col.TsProbe("ts"), Col.U32("tcp_us", nullable: true), Col.U32("tls_us", nullable: true), Col.U8("outcome")]), + [Col.Seq("attempt"), Col.TsDialer("ts"), Col.U32("tcp_us", nullable: true), Col.U32("tls_us", nullable: true), Col.U8("outcome")]), ], p => p.GetInt("per_minute") * 4000 / 60); @@ -126,14 +126,14 @@ public static class Kinds P.Int("port", 7444, 1, 65535), ], [ - new StreamSpec("hold", [Col.TsProbe("ts"), Col.U8("event"), Col.U32("lifetime_ms")]), + new StreamSpec("hold", [Col.TsDialer("ts"), Col.U8("event"), Col.U32("lifetime_ms")]), ], p => 2000 / Math.Max(1, p.GetInt("heartbeat_s")) * 8); public static readonly KindSpec IcmpEcho = new( "icmp_echo", "ICMP echo", - "Probe or Agent side ICMP echo to a target address.", + "拨号方 or Agent side ICMP echo to a target address.", Scope.Target, new BlockSet("icmp", "fixed", "fixed", "echo", []), [ diff --git a/backend/shared/MoShiro.Protocol/Ids/MoShiroIds.cs b/backend/shared/MoShiro.Protocol/Ids/MoShiroIds.cs index 2b9f68e..b894417 100644 --- a/backend/shared/MoShiro.Protocol/Ids/MoShiroIds.cs +++ b/backend/shared/MoShiro.Protocol/Ids/MoShiroIds.cs @@ -3,14 +3,6 @@ using Fleet.Core; namespace MoShiro.Protocol; -[JsonConverter(typeof(ProbeIdJsonConverter))] -public readonly record struct ProbeId(string Value) -{ - public const string Prefix = "prb"; - - public override string ToString() => Value; -} - [JsonConverter(typeof(TaskIdJsonConverter))] public readonly record struct TaskId(string Value) { @@ -42,13 +34,6 @@ public static class AgentIds public const string Prefix = "agt"; } -public sealed class ProbeIdJsonConverter : StringIdJsonConverter -{ - protected override ProbeId Create(string value) => new(value); - - protected override string GetValue(ProbeId id) => id.Value; -} - public sealed class TaskIdJsonConverter : StringIdJsonConverter { protected override TaskId Create(string value) => new(value); diff --git a/backend/shared/MoShiro.Protocol/IpVersions.cs b/backend/shared/MoShiro.Protocol/IpVersions.cs index a7880dc..ef520ae 100644 --- a/backend/shared/MoShiro.Protocol/IpVersions.cs +++ b/backend/shared/MoShiro.Protocol/IpVersions.cs @@ -8,6 +8,6 @@ public static class IpVersions public static bool IsValid(string? value) => value is V4 or V6; - public static string LineSeries(string probeId, string ipVersion) => - RawChunkHeader.LinePrefix + probeId + (ipVersion == V6 ? ":ipv6" : ""); + public static string LineSeries(string dialerId, string ipVersion) => + RawChunkHeader.LinePrefix + dialerId + (ipVersion == V6 ? ":ipv6" : ""); } diff --git a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs index 91c7259..58fb7cd 100644 --- a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs +++ b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs @@ -13,9 +13,10 @@ public static class MoShiroErrors public static readonly ErrorCode SitePrivate = new("site_private"); public static readonly ErrorCode NotFound = new("not_found"); public static readonly ErrorCode AgentNotFound = new("agent_not_found"); - public static readonly ErrorCode ProbeNotFound = new("probe_not_found"); - public static readonly ErrorCode ProbeRevoked = new("probe_revoked"); - public static readonly ErrorCode ProbeRequired = new("probe_required"); + public static readonly ErrorCode NodeRevoked = new("node_revoked"); + public static readonly ErrorCode DialerRequired = new("dialer_required"); + public static readonly ErrorCode NotListening = new("not_listening"); + public static readonly ErrorCode SelfLink = new("self_link"); public static readonly ErrorCode TaskNotFound = new("task_not_found"); public static readonly ErrorCode TestNotFound = new("test_not_found"); public static readonly ErrorCode ChunkNotFound = new("chunk_not_found"); @@ -23,7 +24,7 @@ public static class MoShiroErrors public static readonly ErrorCode UnknownKind = new("unknown_kind"); public static readonly ErrorCode UnknownPlan = new("unknown_plan"); public static readonly ErrorCode NameConflict = new("name_conflict"); - public static readonly ErrorCode ProbeOffline = new("probe_offline"); + public static readonly ErrorCode DialerOffline = new("dialer_offline"); public static readonly ErrorCode TestBusy = new("test_busy"); public static readonly ErrorCode AgentNotActive = new("agent_not_active"); public static readonly ErrorCode AgentOffline = new("agent_offline"); diff --git a/backend/shared/MoShiro.Protocol/ProtocolLimits.cs b/backend/shared/MoShiro.Protocol/ProtocolLimits.cs index 271cfd0..5f791a5 100644 --- a/backend/shared/MoShiro.Protocol/ProtocolLimits.cs +++ b/backend/shared/MoShiro.Protocol/ProtocolLimits.cs @@ -30,7 +30,7 @@ public static class MoShiroChannels /// Agent → Backend:RawChunk(发件箱)。 public const byte RawChunk = 16; - /// Probe → Agent:列块(点对点会话的发件箱)。 + /// 拨号方 → Agent:列块(点对点会话的发件箱)。 public const byte ColumnBlock = 17; } diff --git a/backend/shared/MoShiro.Protocol/Raw/RawChunk.cs b/backend/shared/MoShiro.Protocol/Raw/RawChunk.cs index e7c11ad..5cc8f81 100644 --- a/backend/shared/MoShiro.Protocol/Raw/RawChunk.cs +++ b/backend/shared/MoShiro.Protocol/Raw/RawChunk.cs @@ -4,22 +4,22 @@ using Fleet.Core; namespace MoShiro.Protocol; -/// Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。 +/// 监听方单调时钟与 UTC 的锚点,以及拨号方时钟相对监听方时钟的偏移估计。 public sealed class ChunkClock { - [JsonPropertyName("agent_mono_us")] - public long AgentMonoUs { get; set; } + [JsonPropertyName("listener_mono_us")] + public long ListenerMonoUs { get; set; } [JsonPropertyName("utc_ms")] public long UtcMs { get; set; } - /// Agent 时钟 − Probe 时钟(微秒)。 - [JsonPropertyName("probe_offset_us")] - public long ProbeOffsetUs { get; set; } + /// 监听方时钟 − 拨号方时钟(微秒)。 + [JsonPropertyName("dialer_offset_us")] + public long DialerOffsetUs { get; set; } - /// Probe 相对 Agent 的时钟漂移(ppb,十亿分之一)。 - [JsonPropertyName("probe_drift_ppb")] - public long ProbeDriftPpb { get; set; } + /// 拨号方相对监听方的时钟漂移(ppb,十亿分之一)。 + [JsonPropertyName("dialer_drift_ppb")] + public long DialerDriftPpb { get; set; } } public sealed class ColumnHeader @@ -38,7 +38,7 @@ public sealed class ColumnHeader [JsonPropertyName("nullable")] public bool Nullable { get; set; } - /// ts_probe | ts_agent | ts_local,其他为空。 + /// ts_dialer | ts_listener | ts_local,其他为空。 [JsonPropertyName("role")] public string? Role { get; set; } } @@ -83,8 +83,8 @@ public sealed class RawChunkHeader [JsonPropertyName("agent_id")] public NodeId AgentId { get; set; } - [JsonPropertyName("probe_id")] - public ProbeId? ProbeId { get; set; } + [JsonPropertyName("dialer_id")] + public NodeId? DialerId { get; set; } [JsonPropertyName("target")] public string? Target { get; set; } @@ -105,7 +105,7 @@ public sealed class RawChunkHeader [JsonPropertyName("duration_ms")] public int DurationMs { get; set; } - /// Probe 一侧的列没有在宽限期内送达:只有 Agent 一侧的数据。 + /// 拨号方一侧的列没有在宽限期内送达:只有监听方一侧的数据。 [JsonPropertyName("partial")] public bool Partial { get; set; } @@ -116,11 +116,11 @@ public sealed class RawChunkHeader public List Streams { get; set; } = []; /// - /// "line:<probe_id>" 或 "target:<task_id>":存储和分析用的序列标识。 - /// 同一个 Agent 上每个 Probe 的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。 + /// "line:<dialer_id>" 或 "target:<task_id>":存储和分析用的序列标识。 + /// 同一个监听节点上每个拨号方的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。 /// [JsonIgnore] - public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : IpVersions.LineSeries(ProbeId?.Value ?? "", IpVersion); + public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : IpVersions.LineSeries(DialerId?.Value ?? "", IpVersion); public const string LinePrefix = "line:"; } diff --git a/backend/shared/MoShiro.Protocol/Raw/RawChunkCodec.cs b/backend/shared/MoShiro.Protocol/Raw/RawChunkCodec.cs index f869dc7..b286d80 100644 --- a/backend/shared/MoShiro.Protocol/Raw/RawChunkCodec.cs +++ b/backend/shared/MoShiro.Protocol/Raw/RawChunkCodec.cs @@ -6,7 +6,7 @@ using Fleet.Core; namespace MoShiro.Protocol; /// -/// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。 +/// RawChunk 的编解码(拨号方、Agent、Backend、离线工具共用同一份代码)。 /// 布局:"MSRC" | u8 container_version | u32 crc32c(压缩数据) | Brotli( u32 header_len | header JSON | 各列数据 )。 /// 列数据:可空列先写有效位图,再写有效值;plain 为无符号 varint(有符号类型用 zigzag),delta 为与上一个有效值之差的 zigzag varint。 /// @@ -334,16 +334,16 @@ public static class RawChunkCodec private static string? RoleName(ColumnRole role) => role switch { - ColumnRole.TsProbe => "ts_probe", - ColumnRole.TsAgent => "ts_agent", + ColumnRole.TsDialer => "ts_dialer", + ColumnRole.TsListener => "ts_listener", ColumnRole.TsLocal => "ts_local", _ => null, }; private static ColumnRole ParseRole(string? text) => text switch { - "ts_probe" => ColumnRole.TsProbe, - "ts_agent" => ColumnRole.TsAgent, + "ts_dialer" => ColumnRole.TsDialer, + "ts_listener" => ColumnRole.TsListener, "ts_local" => ColumnRole.TsLocal, _ => ColumnRole.None, }; diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroMethodTable.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroMethodTable.cs index febe4f1..4d5d76f 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroMethodTable.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroMethodTable.cs @@ -2,23 +2,12 @@ using Fleet.Rpc; namespace MoShiro.Protocol; -/// 节点的角色。服务端按节点 ID 的前缀判断;声明表用它限定哪些角色可以参与某个方法或通道。 -public static class NodeRoles -{ - public const string Agent = "agent"; - public const string Probe = "probe"; - - public static string? Of(string nodeId) => - nodeId.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? Agent - : nodeId.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? Probe - : null; -} - /// /// 所有方法和二进制通道的方向声明,单一真源(框架的 fleet.* 见 )。 /// 新增一个方法:在 里加名字和参数,在这里加一行声明,再在对应一侧注册处理函数; /// 声明了但没人处理、或者处理了但没声明,都会被测试和启动时的检查发现。 -/// 同一个方法名只有一个发起方;节点面是服务端和节点(B 和 A、P),对等面是 Probe(拨号方)和 Agent(监听方)。 +/// 同一个方法名只有一个发起方;节点面是服务端和节点,对等面是拨号方和监听方。所有节点是同一种角色: +/// 能做什么由节点的配置(是否监听)和 Backend 下发的期望状态决定,声明表不区分节点类型。 /// public static class MoShiroMethodTable { @@ -27,30 +16,30 @@ public static class MoShiroMethodTable new MethodTable( [ // 节点面:Backend → 节点 - MethodSpec.Request(MoShiroMethods.TestRun, RpcSide.Server, NodeRoles.Agent), - MethodSpec.Request(MoShiroMethods.TestCancel, RpcSide.Server, NodeRoles.Agent), - MethodSpec.Request(MoShiroMethods.ProbeDisconnect, RpcSide.Server, NodeRoles.Agent), - MethodSpec.Notification(MoShiroMethods.ProbeLinkRetry, RpcSide.Server, NodeRoles.Probe), + MethodSpec.Request(MoShiroMethods.TestRun, RpcSide.Server), + MethodSpec.Request(MoShiroMethods.TestCancel, RpcSide.Server), + MethodSpec.Request(MoShiroMethods.DialerDisconnect, RpcSide.Server), + MethodSpec.Notification(MoShiroMethods.LinkRetry, RpcSide.Server), MethodSpec.Notification(MoShiroMethods.UpdateOffer, RpcSide.Server), MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server), MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server), // 节点面:节点 → Backend - MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent), - MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent), - MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node), // Agent 和 Probe 都上报主机资源占用的汇总 + MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node), + MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node), + MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node), - // 对等面:Probe(拨号方)→ Agent(监听方) + // 对等面:拨号方 → 监听方 MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer), - // 对等面:Agent → Probe + // 对等面:监听方 → 拨号方 MethodSpec.Request(MoShiroMethods.TestPhase, RpcSide.Listener), MethodSpec.Request(MoShiroMethods.TestPhaseEnd, RpcSide.Listener), MethodSpec.Notification(MoShiroMethods.TestAbort, RpcSide.Listener), ], [ - // 原始块:只有 Agent 往 Backend 发;列块:Probe 经对等会话发给 Agent - new ChannelSpec(MoShiroChannels.RawChunk, RpcSide.Node, [NodeRoles.Agent]), + // 原始块:节点往 Backend 发;列块:拨号方经对等会话发给监听方 + new ChannelSpec(MoShiroChannels.RawChunk, RpcSide.Node), new ChannelSpec(MoShiroChannels.ColumnBlock, RpcSide.Dialer), ])); } diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index f9daf60..df35297 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -9,8 +9,8 @@ public static class MoShiroMethods { public const string TestRun = "moshiro.test.run"; public const string TestCancel = "moshiro.test.cancel"; - public const string ProbeDisconnect = "moshiro.probe.disconnect"; - public const string ProbeLinkRetry = "moshiro.probe.link_retry"; + public const string DialerDisconnect = "moshiro.dialer.disconnect"; + public const string LinkRetry = "moshiro.link.retry"; public const string TestStatus = "moshiro.test.status"; public const string Event = "moshiro.event"; public const string Host = "moshiro.host"; @@ -18,7 +18,7 @@ public static class MoShiroMethods public const string UpdateApply = "moshiro.update.apply"; public const string Schedule = "moshiro.schedule"; - // Probe ↔ Agent(点对点会话) + // 拨号方 ↔ 监听方(点对点会话) public const string Clock = "moshiro.clock"; public const string TestPhase = "moshiro.test.phase"; public const string TestPhaseEnd = "moshiro.test.phase_end"; @@ -27,8 +27,8 @@ public static class MoShiroMethods public static class AgentEventKinds { - public const string ProbeConnected = "probe_connected"; - public const string ProbeDisconnected = "probe_disconnected"; + public const string DialerConnected = "dialer_connected"; + public const string DialerDisconnected = "dialer_disconnected"; public const string EndpointChanged = "endpoint_changed"; public const string SessionRejected = "session_rejected"; public const string ClockStep = "clock_step"; @@ -36,7 +36,7 @@ public static class AgentEventKinds public const string CpuSaturated = "cpu_saturated"; public static readonly IReadOnlyList All = - [ProbeConnected, ProbeDisconnected, EndpointChanged, SessionRejected, ClockStep, Gap, CpuSaturated]; + [DialerConnected, DialerDisconnected, EndpointChanged, SessionRejected, ClockStep, Gap, CpuSaturated]; } public static class TestStatuses @@ -69,9 +69,9 @@ public sealed class TestRunParams [JsonPropertyName("test_id")] public TestId TestId { get; set; } - /// 在哪个 Probe 上运行:必须是当前连着这个 Agent 的 Probe。 - [JsonPropertyName("probe_id")] - public ProbeId ProbeId { get; set; } + /// 在哪个拨号方上运行:必须是当前连着这个监听方的节点。 + [JsonPropertyName("dialer_id")] + public NodeId DialerId { get; set; } [JsonPropertyName("plan")] public string Plan { get; set; } = ""; @@ -92,18 +92,18 @@ public sealed class TestCancelParams public TestId TestId { get; set; } } -/// Backend → Agent:断开某个 Probe 的会话(Probe 会按退避重连,除非它已经被吊销)。 -public sealed class ProbeDisconnectParams +/// Backend → 监听方:断开某个拨号方的会话(拨号方按退避重连,除非它已经被吊销)。 +public sealed class DialerDisconnectParams { - [JsonPropertyName("probe_id")] - public ProbeId ProbeId { get; set; } + [JsonPropertyName("dialer_id")] + public NodeId DialerId { get; set; } } -/// Backend → Probe: immediately retry the selected Agent's current IP paths. -public sealed class ProbeLinkRetryParams +/// Backend → 拨号方:立即重试到指定监听方的线路。 +public sealed class LinkRetryParams { - [JsonPropertyName("agent_id")] - public NodeId AgentId { get; set; } + [JsonPropertyName("listener_id")] + public NodeId ListenerId { get; set; } } public sealed class TestStatusParams @@ -123,9 +123,9 @@ public sealed class TestStatusParams public sealed class AgentEventParams { - /// probe_connected 时:这个 Probe 的 id、版本、地址和连接时间(Backend 不用等下一次心跳就能显示)。 - [JsonPropertyName("probe")] - public ConnectedProbe? Probe { get; set; } + /// dialer_connected 时:这个拨号方的 id、版本、地址和连接时间(Backend 不用等下一次心跳就能显示)。 + [JsonPropertyName("dialer")] + public ConnectedDialer? Dialer { get; set; } [JsonPropertyName("kind")] public string Kind { get; set; } = ""; @@ -137,13 +137,11 @@ public sealed class AgentEventParams public string? Detail { get; set; } } -public sealed class AgentEndpoint +/// 拨号目标:一个监听节点的地址、端口和固定证书用的公钥。 +public sealed class DialTarget { - [JsonPropertyName("ipv6_host")] - public string? Ipv6Host { get; set; } - - [JsonPropertyName("agent_id")] - public NodeId AgentId { get; set; } + [JsonPropertyName("node_id")] + public NodeId NodeId { get; set; } [JsonPropertyName("name")] public string Name { get; set; } = ""; @@ -151,46 +149,43 @@ public sealed class AgentEndpoint [JsonPropertyName("host")] public string Host { get; set; } = ""; + [JsonPropertyName("ipv6_host")] + public string? Ipv6Host { get; set; } + [JsonPropertyName("tcp_port")] public int TcpPort { get; set; } [JsonPropertyName("quic_ports")] public List QuicPorts { get; set; } = []; - [JsonPropertyName("udp_ports")] - public List LegacyUdpPorts { get; set; } = []; - - /// Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。 + /// 监听方面向拨号方的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和拨号方的身份密钥派生。 [JsonPropertyName("public_key")] public string PublicKey { get; set; } = ""; } -/// 期望状态文档(Fleet state type = probe_agents):Backend 直接下发给每个 Probe 的 Agent 列表。 -public sealed class ProbeAgentList +/// 期望状态文档(Fleet state type = dial_targets):Backend 下发给每个节点的、它要拨出的监听节点列表。 +public sealed class DialTargets { - public const string StateType = "probe_agents"; + public const string StateType = "dial_targets"; [JsonPropertyName("version")] public long Version { get; set; } - [JsonPropertyName("agents")] - public List Agents { get; set; } = []; + [JsonPropertyName("targets")] + public List Targets { get; set; } = []; [JsonPropertyName("trust_chain")] public List? TrustChain { get; set; } } -/// 注册元数据(MoShiro 定义,经 Fleet 的 meta 字段带上来)。 -public sealed class AgentEnrollMeta +/// 注册元数据(MoShiro 定义,经 Fleet 的 meta 字段带上来)。不监听的节点不带端口。 +public sealed class NodeEnrollMeta { - [JsonPropertyName("probe_tcp_port")] - public int ProbeTcpPort { get; set; } = 7443; + [JsonPropertyName("tcp_port")] + public int TcpPort { get; set; } - [JsonPropertyName("probe_quic_ports")] - public List ProbeQuicPorts { get; set; } = []; - - [JsonPropertyName("probe_udp_ports")] - public List? LegacyProbeUdpPorts { get; set; } + [JsonPropertyName("quic_ports")] + public List QuicPorts { get; set; } = []; [JsonPropertyName("public_host")] public string? PublicHost { get; set; } @@ -307,19 +302,39 @@ public sealed class UpdateStatus public long At { get; set; } } -/// Probe 的 fleet.heartbeat 附加字段:更新器的状态。 -public sealed class ProbeHeartbeatExtra +/// 节点的 fleet.heartbeat 附加字段:进程内的计数、拨号侧的线路健康和监听侧的会话快照,没有主机信息。 +public sealed class NodeHeartbeatExtra { /// Node operating state; registration approval and transport presence are reported separately. [JsonPropertyName("runtime_status")] public NodeRuntimeStatus RuntimeStatus { get; set; } = NodeRuntimeStatus.Running; - [JsonPropertyName("quic_links")] - public List? QuicLinks { get; set; } + /// 拨号侧:到每个监听节点(每个 IP 族)的线路健康。 + [JsonPropertyName("links")] + public List? Links { get; set; } [JsonPropertyName("ipv6")] public Ipv6Diagnostic? Ipv6 { get; set; } + /// 监听侧:当前连着的拨号方,每次心跳都是完整的快照,Backend 靠它纠正错过的 dialer_connected / dialer_disconnected 事件。 + [JsonPropertyName("dialers")] + public List Dialers { get; set; } = []; + + [JsonPropertyName("pps_in")] + public long PpsIn { get; set; } + + [JsonPropertyName("pps_out")] + public long PpsOut { get; set; } + + [JsonPropertyName("dropped_invalid")] + public long DroppedInvalid { get; set; } + + [JsonPropertyName("dropped_rate_limited")] + public long DroppedRateLimited { get; set; } + + [JsonPropertyName("queue_bytes")] + public long QueueBytes { get; set; } + [JsonPropertyName("update")] public UpdateStatus? Update { get; set; } @@ -330,23 +345,94 @@ public sealed class ProbeHeartbeatExtra public long? DroppedRuntimeLogs { get; set; } } -/// Probe's rolling QUIC connection health snapshot for one Agent/IP path. -public sealed class ProbeQuicLinkHealth +/// +/// 拨号方到一个监听方(一个 IP 族)的线路健康。控制层(TCP+TLS 长连接,承载控制 RPC 和回传数据)与任务层(测量用的 TCP / QUIC) +/// 各有一个状态机:控制层断开才算两个节点失联,任务层只影响测量。 +/// +public sealed class LinkHealth { - [JsonPropertyName("agent_id")] - public string AgentId { get; set; } = ""; + [JsonPropertyName("listener_id")] + public string ListenerId { get; set; } = ""; [JsonPropertyName("ip_version")] public string IpVersion { get; set; } = IpVersions.V4; - [JsonPropertyName("connected")] - public bool Connected { get; set; } + [JsonPropertyName("control")] + public ControlHealth Control { get; set; } = new(); + [JsonPropertyName("task")] + public TaskHealth Task { get; set; } = new(); + + /// 控制层在线且 QUIC 数据通道就绪:这条线路可以完整测量。 + [JsonIgnore] + public bool Ready => Control.State == ControlState.Up && Task.Quic.State == QuicState.Ready; +} + +/// 某一层最近一次失败的原因:失败发生的阶段、分类和说明。 +public sealed class LinkFault +{ + [JsonPropertyName("phase")] + public string Phase { get; set; } = ""; + + /// timeout、refused、reset、dns、tls、auth、closed、silent、other。 + [JsonPropertyName("kind")] + public string Kind { get; set; } = "other"; + + [JsonPropertyName("message")] + public string? Message { get; set; } + + public override string ToString() => string.IsNullOrEmpty(Message) ? $"{Phase}: {Kind}" : $"{Phase}: {Kind}: {Message}"; +} + +/// 控制层:到 Agent 的 TCP+TLS 长连接,包括认证、时钟同步,以及回传数据的发件箱。 +public sealed class ControlHealth +{ [JsonPropertyName("state")] - public ProbeLinkAttemptState State { get; set; } = ProbeLinkAttemptState.Connecting; + public ControlState State { get; set; } = ControlState.Dialing; + + /// 进入当前状态的时间(Unix 毫秒)。 + [JsonPropertyName("since_ms")] + public long SinceMs { get; set; } [JsonPropertyName("last_error")] - public string? LastError { get; set; } + public LinkFault? LastError { get; set; } + + [JsonPropertyName("retry_attempt")] + public int RetryAttempt { get; set; } + + [JsonPropertyName("retry_in_ms")] + public long? RetryInMs { get; set; } + + /// 发件箱里还没有被确认的回传数据。 + [JsonPropertyName("outbox_bytes")] + public long OutboxBytes { get; set; } + + [JsonPropertyName("outbox_pending")] + public int OutboxPending { get; set; } +} + +/// 任务层:测量自己使用的链路。TCP 类积木自带套接字和重拨,QUIC 数据通道由拨号方单独维护。 +public sealed class TaskHealth +{ + /// 正在运行的 TCP 类线路积木数(tcp_echo、connect、hold、tcp_bulk)。 + [JsonPropertyName("tcp_blocks")] + public int TcpBlocks { get; set; } + + [JsonPropertyName("quic")] + public QuicHealth Quic { get; set; } = new(); +} + +/// QUIC 数据通道(udp_echo、udp_train 使用)的状态、握手统计和传输层统计。 +public sealed class QuicHealth +{ + [JsonPropertyName("state")] + public QuicState State { get; set; } = QuicState.Idle; + + [JsonPropertyName("since_ms")] + public long SinceMs { get; set; } + + [JsonPropertyName("last_error")] + public LinkFault? LastError { get; set; } [JsonPropertyName("retry_attempt")] public int RetryAttempt { get; set; } @@ -389,21 +475,40 @@ public sealed class ProbeQuicLinkHealth public ushort? PathMtu { get; set; } } -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ProbeLinkAttemptState +/// 控制层状态:dialing(DNS、TCP、敲门、TLS)→ authenticating(身份签名)→ syncing(时钟)→ up;失败进入 backoff。 +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ControlState { + [JsonStringEnumMemberName("dialing")] + Dialing, + [JsonStringEnumMemberName("authenticating")] + Authenticating, + [JsonStringEnumMemberName("syncing")] + Syncing, + [JsonStringEnumMemberName("up")] + Up, + [JsonStringEnumMemberName("backoff")] + Backoff, +} + +/// QUIC 数据通道状态:控制层不在线时是 idle;connecting 握手中;ready 可发送;retrying 断开后等待重拨。 +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum QuicState +{ + [JsonStringEnumMemberName("idle")] + Idle, [JsonStringEnumMemberName("connecting")] Connecting, - [JsonStringEnumMemberName("retry_wait")] - RetryWait, - [JsonStringEnumMemberName("connected")] - Connected, + [JsonStringEnumMemberName("ready")] + Ready, + [JsonStringEnumMemberName("retrying")] + Retrying, } -/// Probe 的 fleet.hello 附加字段:系统、架构和构建信息(只用于展示和更新)。 -public sealed class ProbeHelloMeta +/// 节点的 fleet.hello 附加字段:属主、系统与构建信息,以及(监听时)面向拨号方的接入信息。 +public sealed class NodeHelloMeta { - /// 这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。 + /// 这个节点的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。 [JsonPropertyName("owners")] public List? Owners { get; set; } @@ -412,44 +517,31 @@ public sealed class ProbeHelloMeta [JsonPropertyName("platform")] public PlatformInfo? Platform { get; set; } + + [JsonPropertyName("local_limits")] + public LocalLimits? LocalLimits { get; set; } + + /// 监听接入信息;节点不监听时为 null,面板不会把它当作拨号目标。 + [JsonPropertyName("listen")] + public ListenInfo? Listen { get; set; } } -/// fleet.hello 的附加字段。 -public sealed class AgentHelloMeta +/// 节点作为监听方时,面向拨号方的接入信息。 +public sealed class ListenInfo { - /// 这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。 - [JsonPropertyName("owners")] - public List? Owners { get; set; } + [JsonPropertyName("tcp_port")] + public int TcpPort { get; set; } - [JsonPropertyName("ipv6")] - public Ipv6Diagnostic? Ipv6 { get; set; } + [JsonPropertyName("quic_ports")] + public List QuicPorts { get; set; } = []; - /// 本机是否启用了 IPv6 监听。旧 Agent 缺省为 false,面板不会给它安排 IPv6 会话。 + /// 本机是否启用了 IPv6 监听;面板只给启用了的节点安排 IPv6 会话。 [JsonPropertyName("ipv6_supported")] public bool Ipv6Supported { get; set; } - [JsonPropertyName("platform")] - public PlatformInfo? Platform { get; set; } - - /// Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64);面板据此得到证书指纹,并下发给 Probe 用来固定证书和派生敲门密钥。 - [JsonPropertyName("tls_pubkey")] - public string? TlsPublicKey { get; set; } - /// 本机的公网地址(IPv4 公网地址、IPv6 全球单播地址):面板据此自动填 public_host 和 ipv6_host,管理员手填的优先。 [JsonPropertyName("addresses")] public List? Addresses { get; set; } - - [JsonPropertyName("probe_tcp_port")] - public int? ProbeTcpPort { get; set; } - - [JsonPropertyName("probe_quic_ports")] - public List? ProbeQuicPorts { get; set; } - - [JsonPropertyName("probe_udp_ports")] - public List? LegacyProbeUdpPorts { get; set; } - - [JsonPropertyName("local_limits")] - public LocalLimits? LocalLimits { get; set; } } /// @@ -497,19 +589,19 @@ public sealed class HostSnapshot public long AtUtcMs { get; set; } } -/// Agent 眼中的一个已连接 Probe。 -public sealed class ConnectedProbe +/// 监听方眼中的一个已连接拨号方。 +public sealed class ConnectedDialer { [JsonPropertyName("ip_version")] public string IpVersion { get; set; } = IpVersions.V4; [JsonPropertyName("id")] - public ProbeId Id { get; set; } + public NodeId Id { get; set; } [JsonPropertyName("version")] public string? Version { get; set; } - /// Probe 的远端地址(经过 NAT 之后的公网 IP:端口)。 + /// 拨号方的远端地址(经过 NAT 之后的公网 IP:端口)。 [JsonPropertyName("remote")] public string? Remote { get; set; } @@ -517,50 +609,7 @@ public sealed class ConnectedProbe public long SinceUtcMs { get; set; } } -/// fleet.heartbeat 的附加字段:只有进程内的计数,没有主机信息。 -public sealed class AgentHeartbeatExtra -{ - /// Node operating state; registration approval and transport presence are reported separately. - [JsonPropertyName("runtime_status")] - public NodeRuntimeStatus RuntimeStatus { get; set; } = NodeRuntimeStatus.Running; - - [JsonPropertyName("ipv6")] - public Ipv6Diagnostic? Ipv6 { get; set; } - - /// 当前连着的 Probe 会话数(等于 的个数)。 - [JsonPropertyName("sessions")] - public int Sessions { get; set; } - - /// 当前连着的 Probe:每次心跳都是完整的快照,Backend 靠它纠正错过的 probe_connected / probe_disconnected 事件。 - [JsonPropertyName("probes")] - public List Probes { get; set; } = []; - - [JsonPropertyName("pps_in")] - public long PpsIn { get; set; } - - [JsonPropertyName("pps_out")] - public long PpsOut { get; set; } - - [JsonPropertyName("dropped_invalid")] - public long DroppedInvalid { get; set; } - - [JsonPropertyName("dropped_rate_limited")] - public long DroppedRateLimited { get; set; } - - [JsonPropertyName("queue_bytes")] - public long QueueBytes { get; set; } - - [JsonPropertyName("update")] - public UpdateStatus? Update { get; set; } - - [JsonPropertyName("runtime_logs")] - public List? RuntimeLogs { get; set; } - - [JsonPropertyName("dropped_runtime_logs")] - public long? DroppedRuntimeLogs { get; set; } -} - -/// Agent 下发给 Probe 的一条线路任务(期望状态类型 line_task_set 里的一项)。 +/// 监听方下发给拨号方的一条线路任务(期望状态类型 line_task_set 里的一项)。 public sealed class LineTask { [JsonPropertyName("id")] @@ -580,7 +629,7 @@ public sealed class LineTask public int Port { get; set; } } -/// 这条线路的期望状态(Agent → Probe):Probe 按它调和自己一侧的角色。 +/// 这条线路的期望状态(监听方 → 拨号方):拨号方按它调和自己一侧的角色。 public sealed class LineTaskSet { public const string StateType = "line_task_set"; @@ -591,31 +640,28 @@ public sealed class LineTaskSet [JsonPropertyName("tasks")] public List Tasks { get; set; } = []; - /// Agent 的数据面端口(Probe 把包发到这里)。 + /// 监听方的数据面端口(拨号方把包发到这里)。 [JsonPropertyName("quic_ports")] public List QuicPorts { get; set; } = []; - [JsonPropertyName("udp_ports")] - public List LegacyUdpPorts { get; set; } = []; - [JsonPropertyName("tcp_ports")] public List TcpPorts { get; set; } = []; } -/// moshiro.clock:Probe 用它估计与 Agent 单调时钟的偏移(对称往返假设)。 +/// moshiro.clock:拨号方用它估计与监听方单调时钟的偏移(对称往返假设)。 public sealed class ClockParams { - [JsonPropertyName("probe_mono_us")] - public long ProbeMonoUs { get; set; } + [JsonPropertyName("dialer_mono_us")] + public long DialerMonoUs { get; set; } } public sealed class ClockResult { - [JsonPropertyName("agent_mono_us")] - public long AgentMonoUs { get; set; } + [JsonPropertyName("listener_mono_us")] + public long ListenerMonoUs { get; set; } } -/// 按需测试的一个阶段(Agent → Probe):Agent 编排时间,Probe 准备好自己一侧的角色之后应答。 +/// 按需测试的一个阶段(监听方 → 拨号方):监听方编排时间,拨号方准备好自己一侧的角色之后应答。 public sealed class TestPhaseStartParams { [JsonPropertyName("test_id")] @@ -646,7 +692,7 @@ public sealed class TestPhaseStartResult public bool Accepted { get; set; } } -/// 阶段结束(Agent → Probe):Probe 收尾并把自己一侧的列块交给 Agent。sent_count 是 Agent 一侧发出的包数(单向数据流用)。 +/// 阶段结束(监听方 → 拨号方):拨号方收尾并把自己一侧的列块交给监听方。sent_count 是监听方一侧发出的包数(单向数据流用)。 public sealed class TestPhaseEndParams { [JsonPropertyName("test_id")] @@ -662,7 +708,7 @@ public sealed class TestPhaseEndParams public long SentCount { get; set; } } -/// Probe 收尾之后回复:它交给 Agent 的列块个数(Agent 据此等待全部列块到齐再进入下一阶段)。 +/// 拨号方收尾之后回复:它交给监听方的列块个数(监听方据此等待全部列块到齐再进入下一阶段)。 public sealed class TestPhaseEndResult { [JsonPropertyName("fragments")] @@ -675,7 +721,7 @@ public sealed class TestAbortParams public TestId TestId { get; set; } } -/// Probe 一侧的列块放在 RawChunk 头部 params 里的保留键:本块覆盖的序号范围 [from, to)(单向数据流由 Agent 一侧按它取行)。 +/// 拨号方一侧的列块放在 RawChunk 头部 params 里的保留键:本块覆盖的序号范围 [from, to)(单向数据流由监听方一侧按它取行)。 public static class FragmentKeys { public const string SeqFrom = "_seq_from"; @@ -695,21 +741,19 @@ public static class FragmentKeys [JsonSerializable(typeof(TestRunParams))] [JsonSerializable(typeof(TestRunResult))] [JsonSerializable(typeof(TestCancelParams))] -[JsonSerializable(typeof(ProbeDisconnectParams))] -[JsonSerializable(typeof(ProbeLinkRetryParams))] +[JsonSerializable(typeof(DialerDisconnectParams))] +[JsonSerializable(typeof(LinkRetryParams))] [JsonSerializable(typeof(HostSnapshot))] [JsonSerializable(typeof(TestStatusParams))] [JsonSerializable(typeof(AgentEventParams))] -[JsonSerializable(typeof(ProbeAgentList))] +[JsonSerializable(typeof(DialTargets))] [JsonSerializable(typeof(UpdateOfferParams))] [JsonSerializable(typeof(ScheduleParams))] [JsonSerializable(typeof(UpdateStatus))] -[JsonSerializable(typeof(ProbeHeartbeatExtra))] -[JsonSerializable(typeof(ProbeHelloMeta))] -[JsonSerializable(typeof(AgentEnrollMeta))] +[JsonSerializable(typeof(NodeHeartbeatExtra))] +[JsonSerializable(typeof(NodeHelloMeta))] +[JsonSerializable(typeof(NodeEnrollMeta))] [JsonSerializable(typeof(PlatformInfo))] -[JsonSerializable(typeof(AgentHelloMeta))] [JsonSerializable(typeof(Ipv6Diagnostic))] -[JsonSerializable(typeof(AgentHeartbeatExtra))] [JsonSerializable(typeof(RawChunkHeader))] public sealed partial class MoShiroJsonContext : JsonSerializerContext; diff --git a/backend/shared/MoShiro.Protocol/Rpc/NodeRuntimeStatus.cs b/backend/shared/MoShiro.Protocol/Rpc/NodeRuntimeStatus.cs index 333ecb2..2cd73eb 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/NodeRuntimeStatus.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/NodeRuntimeStatus.cs @@ -10,8 +10,8 @@ public enum NodeRuntimeStatus Initializing, [JsonStringEnumMemberName("running")] Running, - [JsonStringEnumMemberName("probe_paused")] - ProbePaused, + [JsonStringEnumMemberName("idle")] + Idle, [JsonStringEnumMemberName("maintenance")] Maintenance, [JsonStringEnumMemberName("stopping")] diff --git a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs index 6094e66..9444778 100644 --- a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs +++ b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs @@ -34,10 +34,10 @@ public sealed class TaskSpec public sealed class PeerSpec { - [JsonPropertyName("probe_id")] - public ProbeId ProbeId { get; set; } + [JsonPropertyName("node_id")] + public NodeId NodeId { get; set; } - /// Probe 的身份公钥(SPKI,base64)。 + /// 拨号方的身份公钥(SPKI,base64)。 [JsonPropertyName("pubkey")] public string PublicKey { get; set; } = ""; } @@ -53,7 +53,7 @@ public sealed class TaskSet [JsonPropertyName("tasks")] public List Tasks { get; set; } = []; - /// 允许连接到这个 Agent 的 Probe 公钥。 + /// 允许拨入这个节点的拨号方公钥。 [JsonPropertyName("peers")] public List Peers { get; set; } = []; diff --git a/backend/shared/MoShiro.Protocol/Wire/MeasurementDatagram.cs b/backend/shared/MoShiro.Protocol/Wire/MeasurementDatagram.cs index e0c829b..11a44e5 100644 --- a/backend/shared/MoShiro.Protocol/Wire/MeasurementDatagram.cs +++ b/backend/shared/MoShiro.Protocol/Wire/MeasurementDatagram.cs @@ -8,7 +8,7 @@ public enum MeasurementDatagramType : byte EchoRequest = 1, EchoReply = 2, - /// 单向数据(udp_train),以及 Probe 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。 + /// 单向数据(udp_train),以及 拨号方 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。 Data = 3, } diff --git a/backend/shared/MoShiro.Protocol/Wire/TcpWire.cs b/backend/shared/MoShiro.Protocol/Wire/TcpWire.cs index d680643..e454cbc 100644 --- a/backend/shared/MoShiro.Protocol/Wire/TcpWire.cs +++ b/backend/shared/MoShiro.Protocol/Wire/TcpWire.cs @@ -6,7 +6,7 @@ using Fleet.Core; namespace MoShiro.Protocol; /// -/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 Probe 发起;TLS 是可选的(ClientHello 以 0x16 开头, +/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 拨号方 发起;TLS 是可选的(ClientHello 以 0x16 开头, /// 明文首帧以 "MN" 开头,所以同一个端口可以区分)。首帧: /// "MN"(2) version(1) session_id(8) stream_id(4) nonce(16) mac(16),认证失败直接关闭连接; /// 之后是 VarInt 长度 | 正文 的消息,回显类消息的正文是 seq(u32) send_ts_us(i64) | padding。 diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/Ipv6DiagnosticsTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/Ipv6DiagnosticsTests.cs index f87462b..f60875b 100644 --- a/backend/shared/tests/MoShiro.Measurement.Tests/Ipv6DiagnosticsTests.cs +++ b/backend/shared/tests/MoShiro.Measurement.Tests/Ipv6DiagnosticsTests.cs @@ -42,7 +42,7 @@ public sealed class Ipv6DiagnosticsTests } [Fact] - public void Probe_DoesNotRequireAnInboundListener_AndUnavailableDataStaysUnknown() + public void Dialer_DoesNotRequireAnInboundListener_AndUnavailableDataStaysUnknown() { Assert.Equal("ready", Ipv6Diagnostics.Evaluate(true, null, true, true).State); Assert.Equal("unknown", Ipv6Diagnostics.Evaluate(true, null, null, null).State); diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs index de75231..e7c654a 100644 --- a/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs +++ b/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs @@ -64,31 +64,31 @@ public sealed class MeasurementTests public void RowStore_RecordsOnlyItsOwnSidesColumns_AndCutsExistingRowsInKeyOrder() { StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!; - var probe = new RowStore(msg, Side.Probe, 64); - Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Probe).Select(c => c.Name)); - Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Agent).Select(c => c.Name)); + var dialer = new RowStore(msg, Side.Dialer, 64); + Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name)); + Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Listener).Select(c => c.Name)); for (int i = 0; i < 4; i++) { - long key = probe.Append(); - probe.Set(key, probe.ColumnIndex("size"), 64); - probe.Set(key, probe.ColumnIndex("t1"), 1000 + i); + long key = dialer.Append(); + dialer.Set(key, dialer.ColumnIndex("size"), 64); + dialer.Set(key, dialer.ColumnIndex("t1"), 1000 + i); } - probe.Set(2, probe.ColumnIndex("t4"), 5000); - RawStream cut = probe.Cut(1, 4, includeKey: true); + dialer.Set(2, dialer.ColumnIndex("t4"), 5000); + RawStream cut = dialer.Cut(1, 4, includeKey: true); Assert.Equal(3, cut.Count); Assert.Equal(["seq", "size", "t1", "t4"], cut.Columns.Select(c => c.Name)); Assert.Equal([1L, 2L, 3L], cut.Find("seq")!.Values); Assert.Equal([false, true, false], cut.Find("t4")!.Valid!); - Assert.Throws(() => probe.ColumnIndex("t2")); + Assert.Throws(() => dialer.ColumnIndex("t2")); } [Fact] public void RowStore_RingOverwritesOldRows_AndNeverReturnsAStaleOne() { - var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 16); + var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 16); store.SetRow(3, [30, 31]); Assert.True(store.TryGet(3, 0, out long t2)); Assert.Equal(30, t2); @@ -111,19 +111,19 @@ public sealed class MeasurementTests private static RawChunk EchoFragment(params (uint Seq, long T1, long? T4)[] rows) { - var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)])); + var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)])); foreach ((uint seq, long t1, long? t4) in rows) { msg.Add(seq, 64, t1, t4); } - return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { ProbeOffsetUs = 1000 } }, [msg.Build()]); + return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]); } [Fact] - public void FragmentMerger_ProbeDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty() + public void FragmentMerger_DialerDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty() { - var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 64); + var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64); agent.SetRow(0, [2000, 2050]); agent.SetRow(2, [4000, 4040]); var source = new Source { Stores = { ["msg"] = agent } }; @@ -138,7 +138,7 @@ public sealed class MeasurementTests } [Fact] - public void FragmentMerger_ProbeDriven_WithoutAnyAgentRecords_KeepsTheProbeRows() + public void FragmentMerger_DialerDriven_WithoutAnyAgentRecords_KeepsTheDialerRows() { RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null)); RawStream msg = Assert.Single(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).Value); @@ -148,7 +148,7 @@ public sealed class MeasurementTests } [Fact] - public void FragmentMerger_RefusesAFragmentWithoutTheProbeColumns() + public void FragmentMerger_RefusesAFragmentWithoutTheDialerColumns() { var bad = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq")])); bad.Add(0); @@ -159,13 +159,13 @@ public sealed class MeasurementTests [Fact] public void FragmentMerger_AgentDriven_TakesRowsFromTheAgent_AndMarksUnreceivedPacketsAsLost() { - var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Agent, 64); + var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Listener, 64); for (uint seq = 0; seq < 5; seq++) { agent.SetRow(seq, [1000, 100 + seq]); } - var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsProbe("t_recv")])); + var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsDialer("t_recv")])); pkt.Add(1, 5000).Add(1, 9999).Add(3, 6000); // sequence 1 arrives twice (the first one counts); 0, 2 and 4 are lost var header = new RawChunkHeader { Kind = "udp_train" }; header.Params[FragmentKeys.SeqFrom] = Json("0"); @@ -187,7 +187,7 @@ public sealed class MeasurementTests [Fact] public void FragmentMerger_StreamsWithoutAgentColumns_PassThroughInDeclaredOrder() { - var conn = new StreamBuilder(new StreamSpec("conn", [Col.U8("outcome"), Col.Seq("attempt"), Col.TsProbe("ts")])); + var conn = new StreamBuilder(new StreamSpec("conn", [Col.U8("outcome"), Col.Seq("attempt"), Col.TsDialer("ts")])); conn.Add(0, 0, 100); var fragment = new RawChunk(new RawChunkHeader { Kind = "connect" }, [conn.Build()]); @@ -213,8 +213,8 @@ public sealed class MeasurementTests public void FragmentMerger_UnionStreams_ConcatenateBothSidesRows_AndEachAgentRowIsDeliveredOnce() { StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!; - Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Agent).Count); // each side owns every column - var agent = new RowStore(info, Side.Agent, 64); + Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Listener).Count); // each side owns every column + var agent = new RowStore(info, Side.Listener, 64); var recorder = new Source { Stores = { ["tcp_info"] = agent } }; agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]); agent.SetRow(agent.Append(), [600, 1, 40_000, 5_000, 10, 8, 120, 1_100, 1]); @@ -230,19 +230,19 @@ public sealed class MeasurementTests Assert.False(merged.Find("conn")!.IsValid(0)); Assert.Equal(1, merged.Find("conn")!.Values[1]); - // The agent's rows were consumed: the next chunk carries only the probe's own rows + // The agent's rows were consumed: the next chunk carries only the dialer's own rows IReadOnlyList second = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value; Assert.Equal(1, second.Single(x => x.Name == "tcp_info").Count); } [Fact] - public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheProbeHasNone() + public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheDialerHasNone() { StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!; - var agent = new RowStore(info, Side.Agent, 64); + var agent = new RowStore(info, Side.Listener, 64); agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]); - // A probe on a platform without kernel sampling sends the echo rows only + // A dialer on a platform without kernel sampling sends the echo rows only RawChunk fragment = EchoFragment((0, 1000, 3000)); IReadOnlyList merged = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source { Stores = { ["tcp_info"] = agent } }).Value; RawStream rows = merged.Single(x => x.Name == "tcp_info"); @@ -353,12 +353,12 @@ public sealed class MeasurementTests continue; } - Assert.Contains(pair.Kind, BlockCatalog.ProbeKinds); + Assert.Contains(pair.Kind, BlockCatalog.DialerKinds); BlockSpec spec = new(kind, ParamResolver.Resolve(kind.Params, null).Values!, 1, 7443, new FragmentScope(null, null, null)); IBlock listener = pair.Listener(env, spec); // 声明了连接键、Agent 一侧有列的流:监听方必须为它保留按键记录,否则合并不出完整的行 - foreach (StreamSpec stream in kind.Streams.Where(s => s.Key is not null && s.ColumnsOf(Side.Agent).Count > 0)) + foreach (StreamSpec stream in kind.Streams.Where(s => s.Key is not null && s.ColumnsOf(Side.Listener).Count > 0)) { Assert.NotNull(listener.Records(stream.Name)); } @@ -389,18 +389,18 @@ public sealed class MeasurementTests [Fact] public void ChunkFinalizer_TakesStartAndDurationFromTheDataTimestamps_NotFromTheDeliveryTime() { - var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 64); + var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64); agent.SetRow(0, [2000, 2050]); RawChunk fragment = EchoFragment((0, 1000, 3000)); IReadOnlyList streams = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source { Stores = { ["msg"] = agent } }).Value; - var anchor = new ClockAnchor(AgentMonoUs: 1_000_000, UtcMs: 1_700_000_000_000); + var anchor = new ClockAnchor(ListenerMonoUs: 1_000_000, UtcMs: 1_700_000_000_000); RawChunk chunk = ChunkFinalizer.Finalize(Kinds.UdpEcho, fragment, streams, new NodeId("agt_x"), anchor, new Dictionary { ["port"] = Json("7443") }, 7443, new ChunkId(Guid.NewGuid())); - // t1 = 1000 us (Probe clock) + offset 1000 = 2000 us on the Agent clock, which is 998 ms before the anchor + // t1 = 1000 us (拨号方 clock) + offset 1000 = 2000 us on the Agent clock, which is 998 ms before the anchor Assert.Equal(1_700_000_000_000 - 998, chunk.Header.StartUtcMs); Assert.Equal(7443, chunk.Header.Port); Assert.False(chunk.Header.Partial); - Assert.Equal(1_000_000, chunk.Header.Clock.AgentMonoUs); + Assert.Equal(1_000_000, chunk.Header.Clock.ListenerMonoUs); } } diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/MsQuicConnectionTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/MsQuicConnectionTests.cs index 81ffc8e..ec845cb 100644 --- a/backend/shared/tests/MoShiro.Measurement.Tests/MsQuicConnectionTests.cs +++ b/backend/shared/tests/MoShiro.Measurement.Tests/MsQuicConnectionTests.cs @@ -29,8 +29,11 @@ public sealed class MsQuicConnectionTests } } - [Fact] - public async Task DatagramConnection_CanSendCloseAndReconnect_WithoutBlockingNativeWorkers() + [Theory] + [InlineData("127.0.0.1", "127.0.0.1")] + [InlineData("::", "127.0.0.1")] + [InlineData("::", "::1")] + public async Task DatagramConnection_CanSendCloseAndReconnect_WithoutBlockingNativeWorkers(string bind, string peer) { var log = new CaptureLog(); var counters = new DataPlaneCounters(); @@ -39,7 +42,7 @@ public sealed class MsQuicConnectionTests 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)); - await using var listener = new MsQuicListener(IPAddress.Loopback, [0], certificate, + await using var listener = new MsQuicListener(IPAddress.Parse(bind), [0], certificate, (connection, data) => { // Bad measurement payloads must be counted without flooding logs. @@ -55,7 +58,7 @@ public sealed class MsQuicConnectionTests { var reply = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Loopback, listener.BoundPorts[0], 0, + MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Parse(peer), listener.BoundPorts[0], 0, key.ExportSubjectPublicKeyInfo(), (_, data) => reply.TrySetResult(data.ToArray()), log, timeout.Token); try { diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/RestartUpdaterTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/RestartUpdaterTests.cs index c9294d1..badb7e9 100644 --- a/backend/shared/tests/MoShiro.Measurement.Tests/RestartUpdaterTests.cs +++ b/backend/shared/tests/MoShiro.Measurement.Tests/RestartUpdaterTests.cs @@ -48,7 +48,7 @@ public sealed class RestartUpdaterTests RestartUpdater updater = Create(builtAt: 5_000); updater.Offer(Offer("dev-bbb", 2_000)); Assert.Equal("up_to_date", updater.Status().State); - updater.Offer(Offer("dev-ccc", 9_000, component: "probe")); + updater.Offer(Offer("dev-ccc", 9_000, component: "backend")); Assert.Equal("up_to_date", updater.Status().State); Assert.False(updater.RequestApply()); } diff --git a/backend/shared/tests/MoShiro.Protocol.Tests/CatalogTests.cs b/backend/shared/tests/MoShiro.Protocol.Tests/CatalogTests.cs index 56cc60e..22e8e51 100644 --- a/backend/shared/tests/MoShiro.Protocol.Tests/CatalogTests.cs +++ b/backend/shared/tests/MoShiro.Protocol.Tests/CatalogTests.cs @@ -99,7 +99,7 @@ public sealed class CatalogTests { Version = 7, Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary { ["rate_hz"] = Json("10") } }], - Peers = [new PeerSpec { ProbeId = new ProbeId("prb_1"), PublicKey = "AAAA" }], + Peers = [new PeerSpec { NodeId = new Fleet.Core.NodeId("agt_1"), PublicKey = "AAAA" }], }; string text = JsonSerializer.Serialize(set, MoShiroJsonContext.Default.TaskSet); TaskSet back = JsonSerializer.Deserialize(text, MoShiroJsonContext.Default.TaskSet)!; @@ -108,14 +108,14 @@ public sealed class CatalogTests Assert.Equal(7, back.Version); Assert.Equal("tsk_1", back.Tasks[0].Id.Value); Assert.Equal(10, back.Tasks[0].Params["rate_hz"].GetInt64()); - Assert.Equal("prb_1", back.Peers[0].ProbeId.Value); + Assert.Equal("agt_1", back.Peers[0].NodeId.Value); } [Fact] - public void AgentEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults() + public void NodeEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults() { - AgentEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.AgentEnrollMeta)!; - Assert.Equal(7443, meta.ProbeTcpPort); + NodeEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.NodeEnrollMeta)!; + Assert.Equal(0, meta.TcpPort); // 没有端口表示这个节点不监听 Assert.Equal(["udp_echo"], meta.Kinds); } diff --git a/backend/shared/tests/MoShiro.Protocol.Tests/NodeRuntimeStatusTests.cs b/backend/shared/tests/MoShiro.Protocol.Tests/NodeRuntimeStatusTests.cs index 99180ab..afac175 100644 --- a/backend/shared/tests/MoShiro.Protocol.Tests/NodeRuntimeStatusTests.cs +++ b/backend/shared/tests/MoShiro.Protocol.Tests/NodeRuntimeStatusTests.cs @@ -7,11 +7,11 @@ public sealed class NodeRuntimeStatusTests [Fact] public void HeartbeatStatus_UsesStableSnakeCaseEnumValues() { - var heartbeat = new AgentHeartbeatExtra { RuntimeStatus = NodeRuntimeStatus.ProbePaused }; - string json = JsonSerializer.Serialize(heartbeat, MoShiroJsonContext.Default.AgentHeartbeatExtra); + var heartbeat = new NodeHeartbeatExtra { RuntimeStatus = NodeRuntimeStatus.Idle }; + string json = JsonSerializer.Serialize(heartbeat, MoShiroJsonContext.Default.NodeHeartbeatExtra); - Assert.Contains("\"runtime_status\":\"probe_paused\"", json, StringComparison.Ordinal); - Assert.Equal(NodeRuntimeStatus.ProbePaused, - JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.AgentHeartbeatExtra)!.RuntimeStatus); + Assert.Contains("\"runtime_status\":\"idle\"", json, StringComparison.Ordinal); + Assert.Equal(NodeRuntimeStatus.Idle, + JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.NodeHeartbeatExtra)!.RuntimeStatus); } } diff --git a/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs b/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs index 06eb7e8..f7bb2d7 100644 --- a/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs +++ b/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs @@ -9,17 +9,17 @@ namespace MoShiro.Protocol.Tests; public sealed class RawChunkCodecTests { [Fact] - public void Header_LegacyDefaultsToIpv4_AndIpv6RoundTripsToAnIndependentSeries() + public void Header_DefaultsToIpv4_AndIpv6RoundTripsToAnIndependentSeries() { - RawChunkHeader legacy = System.Text.Json.JsonSerializer.Deserialize("{\"probe_id\":\"prb_legacy\"}", MoShiroJsonContext.Default.RawChunkHeader)!; + RawChunkHeader legacy = System.Text.Json.JsonSerializer.Deserialize("{\"dialer_id\":\"agt_legacy\"}", MoShiroJsonContext.Default.RawChunkHeader)!; Assert.Equal(IpVersions.V4, legacy.IpVersion); - Assert.Equal("line:prb_legacy", legacy.Series); + Assert.Equal("line:agt_legacy", legacy.Series); RawChunk chunk = SampleChunk(); - chunk.Header.ProbeId = new ProbeId("prb_dual"); + chunk.Header.DialerId = new Fleet.Core.NodeId("agt_dual"); chunk.Header.IpVersion = IpVersions.V6; RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(chunk)).Value; Assert.Equal("ipv6", decoded.Header.IpVersion); - Assert.Equal("line:prb_dual:ipv6", decoded.Header.Series); + Assert.Equal("line:agt_dual:ipv6", decoded.Header.Series); Assert.Equal(chunk.Find("msg")!.Count, decoded.Find("msg")!.Count); } @@ -42,7 +42,7 @@ public sealed class RawChunkCodecTests Assert.Equal(original.Header.ChunkId, decoded.Header.ChunkId); Assert.Equal("udp_echo", decoded.Header.Kind); - Assert.Equal(original.Header.Clock.ProbeOffsetUs, decoded.Header.Clock.ProbeOffsetUs); + Assert.Equal(original.Header.Clock.DialerOffsetUs, decoded.Header.Clock.DialerOffsetUs); RawStream before = original.Find("msg")!; RawStream after = decoded.Find("msg")!; Assert.Equal(before.Count, after.Count); @@ -93,7 +93,7 @@ public sealed class RawChunkCodecTests { // 布局变化(列编码、头部字段、字节序)会让这个测试失败:必须同时升级 container_version 并更新黄金字节。 RawChunk chunk = SampleChunk(); - chunk.Header.Clock = new ChunkClock { AgentMonoUs = 1000, UtcMs = 1_700_000_000_000, ProbeOffsetUs = 500 }; + chunk.Header.Clock = new ChunkClock { ListenerMonoUs = 1000, UtcMs = 1_700_000_000_000, DialerOffsetUs = 500 }; byte[] body = RawChunkCodec.EncodeBody(chunk); uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(body); string columns = Convert.ToHexStringLower(body.AsSpan(4 + (int)headerLength)); diff --git a/backend/src/MoShiro.Backend.Analysis/Engine/ChunkView.cs b/backend/src/MoShiro.Backend.Analysis/Engine/ChunkView.cs index 3208a72..0d3cd55 100644 --- a/backend/src/MoShiro.Backend.Analysis/Engine/ChunkView.cs +++ b/backend/src/MoShiro.Backend.Analysis/Engine/ChunkView.cs @@ -10,8 +10,8 @@ public interface IObservationSink } /// -/// 原始块的列视图:按列名读取,并把 Probe / Agent 各自的单调时钟换算成 UTC。 -/// Agent 时钟 → UTC 使用块头的锚点;Probe 时钟 → Agent 时钟使用估计的偏移。 +/// 原始块的列视图:按列名读取,并把 拨号方 / 监听方 各自的单调时钟换算成 UTC。 +/// Agent 时钟 → UTC 使用块头的锚点;拨号方 时钟 → Agent 时钟使用估计的偏移。 /// public sealed class ChunkView { @@ -24,11 +24,11 @@ public sealed class ChunkView public RawChunkHeader Header => Chunk.Header; - public long AgentToUtcMs(long agentUs) => Header.Clock.UtcMs + FloorDiv(agentUs - Header.Clock.AgentMonoUs, 1000); + public long AgentToUtcMs(long agentUs) => Header.Clock.UtcMs + FloorDiv(agentUs - Header.Clock.ListenerMonoUs, 1000); - public long ProbeToUtcMs(long probeUs) => AgentToUtcMs(probeUs + Header.Clock.ProbeOffsetUs); + public long DialerToUtcMs(long dialerUs) => AgentToUtcMs(dialerUs + Header.Clock.DialerOffsetUs); - public long ColumnToUtcMs(RawColumn column, long value) => column.Role == ColumnRole.TsProbe ? ProbeToUtcMs(value) : AgentToUtcMs(value); + public long ColumnToUtcMs(RawColumn column, long value) => column.Role == ColumnRole.TsDialer ? DialerToUtcMs(value) : AgentToUtcMs(value); public double ParamNumber(string name, double fallback) => Header.Params.TryGetValue(name, out JsonElement v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out double d) ? d : fallback; diff --git a/backend/src/MoShiro.Backend.Analysis/Engine/TestEvaluator.cs b/backend/src/MoShiro.Backend.Analysis/Engine/TestEvaluator.cs index 5988e98..05c769d 100644 --- a/backend/src/MoShiro.Backend.Analysis/Engine/TestEvaluator.cs +++ b/backend/src/MoShiro.Backend.Analysis/Engine/TestEvaluator.cs @@ -142,7 +142,7 @@ public static class TestEvaluator step.JitterP95Ms = jh.Quantile(0.95); } - // 阶段的时长:从发送时间戳算(块头里的时长是 Probe 填的,每个块可能都带着整个阶段的时长,不能相加也不能取并集) + // 阶段的时长:从发送时间戳算(块头里的时长是 拨号方 填的,每个块可能都带着整个阶段的时长,不能相加也不能取并集) double spanSeconds = SendSpanSeconds(parts) ?? ((parts.Max(c => c.Header.StartUtcMs + c.Header.DurationMs) - parts.Min(c => c.Header.StartUtcMs)) / 1000.0); step.DeliveredMbps = spanSeconds > 0 ? bytes * 8 / spanSeconds / 1_000_000 : 0; steps.Add(step); @@ -215,7 +215,7 @@ public static class TestEvaluator continue; } - long utcUs = (clock.UtcMs * 1000) + (send.Values[i] - clock.AgentMonoUs); + long utcUs = (clock.UtcMs * 1000) + (send.Values[i] - clock.ListenerMonoUs); min = Math.Min(min, utcUs); max = Math.Max(max, utcUs); count++; diff --git a/backend/src/MoShiro.Backend.Analysis/Metrics/LinkMetrics.cs b/backend/src/MoShiro.Backend.Analysis/Metrics/LinkMetrics.cs index 31e094b..403b2bf 100644 --- a/backend/src/MoShiro.Backend.Analysis/Metrics/LinkMetrics.cs +++ b/backend/src/MoShiro.Backend.Analysis/Metrics/LinkMetrics.cs @@ -11,10 +11,10 @@ public static class LinkMetrics [Stat.P50, Stat.P95, Stat.P99, Stat.Mean], good: 50, poor: 150); public static readonly MetricDef LossUp = Metric.Ratio( - "loss.up", s_udpMsg, Ops.Loss(Dir.Up), "Loss (up)", "Packets lost between the Probe and the Agent.", good: 0.5, poor: 3); + "loss.up", s_udpMsg, Ops.Loss(Dir.Up), "Loss (up)", "Packets lost between the 拨号方 and the Agent.", good: 0.5, poor: 3); public static readonly MetricDef LossDown = Metric.Ratio( - "loss.down", s_udpMsg, Ops.Loss(Dir.Down), "Loss (down)", "Replies lost between the Agent and the Probe, among packets that reached the Agent.", good: 0.5, poor: 3); + "loss.down", s_udpMsg, Ops.Loss(Dir.Down), "Loss (down)", "Replies lost between the Agent and the 拨号方, among packets that reached the Agent.", good: 0.5, poor: 3); public static readonly MetricDef LossTotal = Metric.Ratio( "loss.total", s_udpOrIcmpMsg, Ops.Loss(Dir.Total), "Loss (total)", "Packets whose reply never came back.", good: 0.5, poor: 3); @@ -31,11 +31,11 @@ public static class LinkMetrics new MetricExport("loss.run_mean", Stat.Mean, Unit.Packets)); public static readonly MetricDef LossRunUp = Metric.Max( - "loss.run.up", s_udpMsg, Ops.LossRunMax(Dir.Up), "Upstream longest loss run", "Longest consecutive Probe-to-Agent Datagram loss run in each chunk; zero means no loss.", + "loss.run.up", s_udpMsg, Ops.LossRunMax(Dir.Up), "Upstream longest loss run", "Longest consecutive 拨号方-to-Agent Datagram loss run in each chunk; zero means no loss.", Unit.Packets, "loss.run.up_max", good: 0, poor: 10); public static readonly MetricDef LossRunDown = Metric.Max( - "loss.run.down", s_udpMsg, Ops.LossRunMax(Dir.Down), "Downstream longest loss run", "Longest consecutive Agent-to-Probe Datagram loss run in each chunk, conditioned on the request reaching the Agent; zero means no loss.", + "loss.run.down", s_udpMsg, Ops.LossRunMax(Dir.Down), "Downstream longest loss run", "Longest consecutive Agent-to-拨号方 Datagram loss run in each chunk, conditioned on the request reaching the Agent; zero means no loss.", Unit.Packets, "loss.run.down_max", good: 0, poor: 10); public static readonly MetricDef Reorder = Metric.Counter( diff --git a/backend/src/MoShiro.Backend.Analysis/Ops/EchoOps.cs b/backend/src/MoShiro.Backend.Analysis/Ops/EchoOps.cs index e52be7c..29747ec 100644 --- a/backend/src/MoShiro.Backend.Analysis/Ops/EchoOps.cs +++ b/backend/src/MoShiro.Backend.Analysis/Ops/EchoOps.cs @@ -15,7 +15,7 @@ public enum GapOutput MaxMs, } -/// 回显流(stream msg)的列:t1/t4 是 Probe 时钟,t2/t3 是 Agent 时钟;缺失的 t2 表示上行丢失,缺失的 t4 表示下行丢失。 +/// 回显流(stream msg)的列:t1/t4 是 拨号方 时钟,t2/t3 是 Agent 时钟;缺失的 t2 表示上行丢失,缺失的 t4 表示下行丢失。 internal readonly struct EchoColumns { public EchoColumns(RawStream stream) diff --git a/backend/src/MoShiro.Backend.Analysis/Ops/SessionOps.cs b/backend/src/MoShiro.Backend.Analysis/Ops/SessionOps.cs index 70e41e4..2479342 100644 --- a/backend/src/MoShiro.Backend.Analysis/Ops/SessionOps.cs +++ b/backend/src/MoShiro.Backend.Analysis/Ops/SessionOps.cs @@ -2,11 +2,11 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Analysis; -/// tcp_info 流的辅助:side 为 0 表示 Probe 一侧记录(Probe 时钟),否则为 Agent 一侧。 +/// tcp_info 流的辅助:side 为 0 表示 拨号方 一侧记录(拨号方 时钟),否则为 Agent 一侧。 internal static class TcpInfoTime { public static long ToUtcMs(ChunkView view, RawColumn ts, RawColumn? side, int row) => - side is not null && side.Values[row] == 0 ? view.ProbeToUtcMs(ts.Values[row]) : view.AgentToUtcMs(ts.Values[row]); + side is not null && side.Values[row] == 0 ? view.DialerToUtcMs(ts.Values[row]) : view.AgentToUtcMs(ts.Values[row]); } /// 两次采样之间累计计数的差值(分子)与另一个累计计数的差值(分母),按 (side, conn) 分别计算;旧数据没有 conn 时按 side 计算。 @@ -164,7 +164,7 @@ internal sealed class BulkRateOp : MetricOp } double mbps = total * 8 / (width * sampleMs / 1000.0) / 1_000_000; - sink.Emit(view.ProbeToUtcMs(bin * binUs), mbps); + sink.Emit(view.DialerToUtcMs(bin * binUs), mbps); } } } @@ -332,7 +332,7 @@ internal sealed class TrainLossOp : MetricOp } } -/// 单向时延变化:t_recv(Probe) − t_send(Agent),去除偏移和漂移。 +/// 单向时延变化:t_recv(拨号方) − t_send(Agent),去除偏移和漂移。 internal sealed class TrainJitterOp : MetricOp { public override string Signature => "train_jitter/1"; diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index a1ef467..43823dc 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -8,27 +8,26 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Api; -/// Agent 管理:列表、修改、审批、启停、接管、删除;加入令牌;Probe 身份。 +/// 节点管理:列表、修改、审批、启停、接管、删除;加入令牌。 public static class AgentEndpoints { private const string AgentsTag = "admin-agents"; private const string TokensTag = "admin-join-tokens"; - private const string ProbeTag = "admin-probe"; public static void Map(RouteGroupBuilder group) { group.MapGet("/agents", ListAsync) - .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403) + .Api("agents_list", "All nodes (agents) including pending and disabled ones; each says whether it listens, which dialers are connected to it and which listeners it dials", AgentsTag).Returns>().ProducesProblems(401, 403) .Requires(Capability.FleetRead); group.MapGet("/agents/{id}", GetAsync) .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404) .Requires(Capability.FleetRead); group.MapGet("/nodes/{id}/logs", ListNodeLogsAsync) - .Api("node_runtime_logs", "Recent Agent or Probe runtime logs", "admin-nodes").Returns().ProducesProblems(401, 403) + .Api("node_runtime_logs", "Recent runtime logs of a node", "admin-nodes").Returns().ProducesProblems(401, 403) .Requires(Capability.FleetRead); group.MapPatch("/agents/{id}", PatchAsync) .Accepts("application/merge-patch+json", "application/json") - .Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag) + .Api("agents_update", "Update name, labels, public host, ISP or visibility (merge-patch, If-Match supported)", AgentsTag) .Returns().ProducesProblems(400, 401, 403, 404, 409, 412) .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct))) @@ -53,7 +52,7 @@ public static class AgentEndpoints .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().ProducesProblems(401, 403) .Requires(Capability.FleetRead); group.MapPost("/join-tokens", CreateTokenAsync) - .Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag) + .Api("join_tokens_create", "Create a join token for an agent; the plaintext is returned only once", TokensTag) .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403) .Requires(Capability.FleetEnroll); group.MapDelete("/join-tokens/{id}", RevokeTokenAsync) @@ -61,25 +60,8 @@ public static class AgentEndpoints .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) .Requires(Capability.FleetEnroll); - group.MapGet("/probes", ListProbesAsync) - .Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns>().ProducesProblems(401, 403) - .Requires(Capability.FleetRead); - group.MapGet("/probes/{id}", GetProbeAsync) - .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404) - .Requires(Capability.FleetRead); - group.MapPatch("/probes/{id}", PatchProbeAsync) - .Api("probes_update", "Rename a probe", ProbeTag).Returns().ProducesProblems(400, 401, 403, 404, 409) - .Requires(Capability.FleetWrite); - group.MapPost("/probes/{id}/revoke", RevokeProbeAsync) - .Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag) - .Returns().ProducesProblems(401, 403, 404) - .Requires(Capability.FleetRevoke); - group.MapDelete("/probes/{id}", DeleteProbeAsync) - .Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) - .Requires(Capability.FleetRevoke); - group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync) - .Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag) + group.MapPost("/agents/{id}/dialers/{dialer_id}/disconnect", DisconnectDialerAsync) + .Api("agents_dialer_disconnect", "Make a listening node drop one dialer's session (the dialer reconnects unless it is disabled)", AgentsTag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409) .Requires(Capability.FleetWrite); } @@ -107,8 +89,13 @@ public static class AgentEndpoints return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto); } - private static async Task PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct) + private static async Task PatchAsync(HttpContext http, AgentService agents, NodeInfoService nodes, AgentPatchRequest request, string id, CancellationToken ct) { + if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null) + { + return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto")); + } + Result result = await agents.PatchAsync( new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicHostAuto = request.PublicHostAuto, Ipv6HostAuto = request.Ipv6HostAuto, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct); if (!result.TryGet(out AgentView? view, out FleetError? error)) @@ -116,6 +103,11 @@ public static class AgentEndpoints return Problems.From(http, error); } + if (request.Isp is not null) + { + await nodes.SetIspAsync(id, request.Isp == "auto" ? null : request.Isp, ct); + } + Concurrency.SetETag(http, view.Agent.RowVersion); return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.AgentDto); } @@ -182,62 +174,14 @@ public static class AgentEndpoints return TypedResults.NoContent(); } - private static async Task ListProbesAsync(ProbeService probes, CancellationToken ct) => - TypedResults.Json(new PageDto([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto); - - private static async Task GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) => - await probes.GetAsync(id, ct) is { } view - ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) - : Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found"); - - private static async Task PatchProbeAsync(HttpContext http, ProbeService probes, NodeInfoService nodes, PatchProbeRequest request, string id, CancellationToken ct) + private static async Task DisconnectDialerAsync(HttpContext http, AgentService agents, string id, string dialer_id, CancellationToken ct) { - if (request.Name is null && request.Isp is null) - { - return Problems.From(http, ValidationError.Single("name", "name or isp is required")); - } - - if (request.Isp is not null && request.Isp != "auto" && IspClassifier.NameOf(request.Isp) is null) - { - return Problems.From(http, ValidationError.Single("isp", "must be telecom, unicom, mobile, broadnet, other or auto")); - } - - Result result = request.Name is null - ? (await probes.GetAsync(id, ct) is { } existing ? Result.Ok(existing) : Result.Fail(MoShiroErrors.ProbeNotFound, "probe not found")) - : await probes.RenameAsync(id, request.Name, ct); - if (!result.TryGet(out ProbeView? view, out FleetError? error)) - { - return Problems.From(http, error); - } - - if (request.Isp is not null) - { - await nodes.SetIspAsync(id, NodeKinds.Probe, request.Isp == "auto" ? null : request.Isp, ct); - } - - return TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto); - } - - private static async Task RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) - { - Result result = await probes.RevokeAsync(id, ct); - return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error); - } - - private static async Task DeleteProbeAsync(ProbeService probes, string id, CancellationToken ct) - { - await probes.DeleteAsync(id, ct); - return TypedResults.NoContent(); - } - - private static async Task DisconnectProbeAsync(HttpContext http, ProbeService probes, string id, string probe_id, CancellationToken ct) - { - Result result = await probes.DisconnectAsync(probe_id, new NodeId(id), ct); + Result result = await agents.DisconnectDialerAsync(new NodeId(id), new NodeId(dialer_id), ct); if (!result.TryGet(out bool dropped, out FleetError? error)) { return Problems.From(http, error); } - return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.ProbeOffline, "this probe has no session with this agent"); + return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.DialerOffline, "this dialer has no session with this node"); } } diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index 388b9b6..3b7d7b8 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -16,14 +16,14 @@ public static class BuildEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/build-info", GetAsync) - .Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag) + .Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every node", Tag) .Returns().ProducesProblems(401, 403) .Requires(Capability.UpdateRead); group.MapGet("/releases", ListReleasesAsync) .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().ProducesProblems(401, 403) .Requires(Capability.UpdateRead); group.MapPost("/nodes/{id}/update", RequestUpdateAsync) - .Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409) + .Api("nodes_update", "Ask an online node to install the release it has been offered", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409) .Requires(Capability.UpdateConfirm); group.MapGet("/backend/update", BackendUpdateAsync) .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().ProducesProblems(401, 403) @@ -65,9 +65,8 @@ public static class BuildEndpoints { IReadOnlyDictionary> ownerOf = await owners.ListAsync(ct); var items = (await nodes.ListAsync(ct)) - .OrderBy(n => n.Kind, StringComparer.Ordinal) - .ThenBy(n => n.NodeId, StringComparer.Ordinal) - .Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null)) + .OrderBy(n => n.NodeId, StringComparer.Ordinal) + .Select(n => new NodeBuildDto(n.NodeId, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), ownerOf.TryGetValue(n.NodeId, out IReadOnlyList? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null)) .ToList(); http.Response.Headers.CacheControl = "no-store"; return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto); diff --git a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs index b9ebeac..de1c341 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs @@ -1,4 +1,5 @@ using Fleet.Core; +using Fleet.Server; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -7,7 +8,10 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Api; -/// 线路开关:哪些(Probe,Agent)要连。自动连接的总开关是设置 fleet.auto_link;这里看每一对的结果,并且手动强制开、关或取消手动设置。 +/// +/// 线路开关:哪些(拨号方,监听方)要连。自动连接的总开关是设置 fleet.auto_link(不监听的节点自动拨所有监听节点); +/// 这里看每一对的结果,并且手动强制开、关或取消手动设置。把一个节点拖到另一个监听节点上建立线路,就是 PUT 一个 enabled: true。 +/// public static class LinkEndpoints { private const string Tag = "admin-links"; @@ -15,110 +19,139 @@ public static class LinkEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/links", ListAsync) - .Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns().ProducesProblems(401, 403) + .Api("links_list", "Every possible dialer/listener pair: whether it is linked, the manual override and whether it is connected; plus the nodes and which of them listen", Tag).Returns().ProducesProblems(401, 403) .Requires(Capability.FleetRead); - group.MapPut("/links/{probe_id}/{agent_id}", SetAsync) - .Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns().ProducesProblems(400, 401, 403, 404) + group.MapPut("/links/{dialer_id}/{listener_id}", SetAsync) + .Api("links_set", "Connect a node to a listening node (enabled: true), force the link off (false), or give it back to the automatic setting (null)", Tag).Returns().ProducesProblems(400, 401, 403, 404, 409, 422) .Requires(Capability.FleetWrite); - group.MapPost("/links/{probe_id}/{agent_id}/retry", RetryAsync) - .Api("links_retry", "Enable and immediately retry the Probe-to-Agent connection", Tag).Returns().ProducesProblems(401, 403, 404) + group.MapPost("/links/{dialer_id}/{listener_id}/retry", RetryAsync) + .Api("links_retry", "Enable and immediately retry the dialer-to-listener connection", Tag).Returns().ProducesProblems(401, 403, 404, 409, 422) .Requires(Capability.FleetWrite); } - private static async Task ListAsync(AgentService agents, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, CancellationToken ct) + private static async Task ListAsync(AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, CancellationToken ct) { LinkSnapshot snapshot = await links.SnapshotAsync(ct); - IReadOnlyList agentViews = await agents.ListAsync(ct); + AgentView[] nodes = [.. (await agents.ListAsync(ct)).Where(n => n.Agent.Status == NodeStatus.Active).OrderBy(n => n.Agent.Name, StringComparer.Ordinal)]; var items = new List(); - foreach (ProbeView probe in (await probes.ListAsync(ct)).Where(p => !p.Revoked).OrderBy(p => p.Probe.Name, StringComparer.Ordinal)) + foreach (AgentView dialer in nodes) { - foreach (AgentView agent in agentViews.OrderBy(a => a.Agent.Name, StringComparer.Ordinal)) + foreach (AgentView listener in nodes.Where(n => n.Agent.Listens && n.Agent.Id != dialer.Agent.Id)) { - items.Add(ToDto(snapshot, probe, agent, quic, time)); + items.Add(ToDto(snapshot, dialer, listener, runtime, time)); } } - return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, items), ApiJsonContext.Default.LinkMatrixDto); + LinkNodeDto[] nodeItems = [.. nodes.Select(n => new LinkNodeDto(n.Agent.Id.Value, n.Agent.Name, n.Agent.Listens, n.Online, n.RuntimeStatus))]; + return TypedResults.Json(new LinkMatrixDto(snapshot.Auto, nodeItems, items), ApiJsonContext.Default.LinkMatrixDto); } - private static async Task SetAsync(HttpContext http, AgentService agents, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, string probe_id, string agent_id, SetLinkRequest request, CancellationToken ct) + private static async Task SetAsync(HttpContext http, AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, string dialer_id, string listener_id, SetLinkRequest request, CancellationToken ct) { - if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe) + (AgentView? dialer, AgentView? listener, IResult? problem) = await ResolveAsync(http, agents, dialer_id, listener_id, request.Enabled == true, ct); + if (problem is not null) { - return Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found"); + return problem; } - if (await agents.GetAsync(new NodeId(agent_id), ct) is not { } agent) - { - return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found"); - } - - await links.SetAsync(probe_id, agent_id, request.Enabled, ct); - await agents.AfterAgentSetChangedAsync(ct); // 重新下发:Probe 的 Agent 列表、Agent 允许的 Probe + await links.SetAsync(dialer_id, listener_id, request.Enabled, ct); + await agents.AfterAgentSetChangedAsync(ct); // 重新下发:拨号方的拨号目标、监听方允许的拨号方 if (request.Enabled == true) { - await agents.NotifyProbeLinkRetryAsync(new NodeId(probe_id), agent.Agent.Id, ct); + await agents.NotifyLinkRetryAsync(dialer!.Agent.Id, listener!.Agent.Id, ct); } - return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), probe, agent, quic, time), ApiJsonContext.Default.LinkDto); + + return TypedResults.Json(ToDto(await links.SnapshotAsync(ct), dialer!, listener!, runtime, time), ApiJsonContext.Default.LinkDto); } - private static async Task RetryAsync(HttpContext http, AgentService agents, ProbeService probes, LinkService links, ProbeQuicRuntime quic, TimeProvider time, string probe_id, string agent_id, CancellationToken ct) + private static async Task RetryAsync(HttpContext http, AgentService agents, LinkService links, LinkRuntime runtime, TimeProvider time, string dialer_id, string listener_id, CancellationToken ct) { - if (await probes.GetAsync(probe_id, ct) is not { Revoked: false } probe) + (AgentView? dialer, AgentView? listener, IResult? problem) = await ResolveAsync(http, agents, dialer_id, listener_id, requireListening: true, ct); + if (problem is not null) { - return Problems.Create(http, MoShiroErrors.ProbeNotFound, "probe not found"); - } - if (await agents.GetAsync(new NodeId(agent_id), ct) is not { } agent) - { - return Problems.Create(http, MoShiroErrors.AgentNotFound, "agent not found"); + return problem; } - await links.SetAsync(probe_id, agent_id, true, ct); + await links.SetAsync(dialer_id, listener_id, true, ct); await agents.AfterAgentSetChangedAsync(ct); - bool queued = await agents.NotifyProbeLinkRetryAsync(new NodeId(probe_id), agent.Agent.Id, ct); - LinkDto link = ToDto(await links.SnapshotAsync(ct), probe, agent, quic, time); + bool queued = await agents.NotifyLinkRetryAsync(dialer!.Agent.Id, listener!.Agent.Id, ct); + LinkDto link = ToDto(await links.SnapshotAsync(ct), dialer, listener, runtime, time); return TypedResults.Json(new LinkRetryDto(queued, link), ApiJsonContext.Default.LinkRetryDto, statusCode: StatusCodes.Status202Accepted); } - private static LinkDto ToDto(LinkSnapshot snapshot, ProbeView probe, AgentView agent, ProbeQuicRuntime quicRuntime, TimeProvider time) + /// 两端都得是存在且激活的节点,不能连自己;开线路时监听方必须在监听。 + private static async Task<(AgentView? Dialer, AgentView? Listener, IResult? Problem)> ResolveAsync(HttpContext http, AgentService agents, string dialerId, string listenerId, bool requireListening, CancellationToken ct) { - bool linked = snapshot.IsLinked(probe.Probe.Id, agent.Agent.Id.Value); - ProbeQuicLinkSnapshot? quicSnapshot = quicRuntime.Get(probe.Probe.Id, agent.Agent.Id.Value, IpVersions.V4); - ProbeQuicLinkHealth? health = quicSnapshot?.Health; - bool fresh = quicSnapshot is not null && time.GetUtcNow().ToUnixTimeMilliseconds() - quicSnapshot.AtMs <= 30_000; + if (dialerId == listenerId) + { + return (null, null, Problems.Create(http, MoShiroErrors.SelfLink, "a node cannot connect to itself")); + } + + if (await agents.GetAsync(new NodeId(dialerId), ct) is not { } dialer) + { + return (null, null, Problems.Create(http, MoShiroErrors.AgentNotFound, "dialer not found")); + } + + if (await agents.GetAsync(new NodeId(listenerId), ct) is not { } listener) + { + return (null, null, Problems.Create(http, MoShiroErrors.AgentNotFound, "listener not found")); + } + + if (requireListening && !listener.Agent.Listens) + { + return (null, null, Problems.Create(http, MoShiroErrors.NotListening, "the listener does not accept connections: only nodes that listen can be connected to")); + } + + return (dialer, listener, null); + } + + private static LinkDto ToDto(LinkSnapshot snapshot, AgentView dialer, AgentView listener, LinkRuntime runtime, TimeProvider time) + { + bool linked = snapshot.IsLinked(dialer.Agent, listener.Agent); + ReportedLink? reported = runtime.Get(dialer.Agent.Id.Value, listener.Agent.Id.Value, IpVersions.V4); + LinkHealth? health = reported?.Health; + bool fresh = reported is not null && time.GetUtcNow().ToUnixTimeMilliseconds() - reported.AtMs <= 30_000; string state; string? reason = null; if (!linked) { state = "disabled"; reason = "线路策略已关闭"; } - else if (probe.RuntimeStatus != NodeRuntimeStatus.Running) { state = "probe_unavailable"; reason = $"Probe 状态:{probe.RuntimeStatus}"; } - else if (agent.RuntimeStatus != NodeRuntimeStatus.Running) { state = "agent_unavailable"; reason = $"Agent 状态:{agent.RuntimeStatus}"; } + else if (dialer.RuntimeStatus != NodeRuntimeStatus.Running) { state = "dialer_unavailable"; reason = $"拨号节点状态:{dialer.RuntimeStatus}"; } + else if (listener.RuntimeStatus != NodeRuntimeStatus.Running) { state = "listener_unavailable"; reason = $"监听节点状态:{listener.RuntimeStatus}"; } else if (health is null || !fresh) { state = "waiting_report"; - reason = health is null ? "Probe 尚未上报此线路的 QUIC 拨号状态" : "Probe 的线路状态上报已过期"; + reason = health is null ? "拨号节点尚未上报此线路的状态" : "拨号节点的线路状态上报已过期"; } - else if (health.Connected && agent.Probes.Any(p => p.Id == probe.Probe.Id)) + else if (health.Control.State == ControlState.Backoff) + { + state = "control_down"; + reason = health.Control.LastError is { } fault ? $"与监听节点失联:{fault}" : "与监听节点失联,等待自动重试"; + } + else if (health.Control.State != ControlState.Up || listener.Dialers.All(d => d.Id != dialer.Agent.Id.Value)) + { + state = "control_connecting"; + reason = "正在建立到监听节点的控制连接"; + } + else if (health.Task.Quic.State == QuicState.Ready) { state = "connected"; } - else if (health.State == ProbeLinkAttemptState.RetryWait) + else if (health.Task.Quic.State == QuicState.Retrying) { - state = "retry_wait"; - reason = health.LastError is { Length: > 0 } error ? $"QUIC 连接失败:{error}" : "QUIC 连接失败,等待自动重试"; - } - else if (agent.Probes.All(p => p.Id != probe.Probe.Id)) - { - state = "control_disconnected"; - reason = health.LastError ?? "Probe 尚未建立到 Agent 的测量控制会话"; + state = "task_retrying"; + reason = health.Task.Quic.LastError is { } fault ? $"控制连接正常,QUIC 数据通道重试中:{fault}" : "控制连接正常,QUIC 数据通道重试中"; } else { - state = "quic_connecting"; - reason = health.LastError ?? "控制会话已建立,正在建立 QUIC 测量会话"; + state = "task_connecting"; + reason = "控制连接正常,正在建立 QUIC 数据通道"; } - bool connected = state == "connected"; - return new LinkDto(probe.Probe.Id, probe.Probe.Name, agent.Agent.Id.Value, agent.Agent.Name, linked, - snapshot.OverrideOf(probe.Probe.Id, agent.Agent.Id.Value), connected, probe.RuntimeStatus, agent.RuntimeStatus, - state, reason, health?.LastError, health?.RetryAttempt ?? 0, health?.RetryInMs); + + bool inBackoff = health?.Control.State == ControlState.Backoff; + return new LinkDto(dialer.Agent.Id.Value, dialer.Agent.Name, listener.Agent.Id.Value, listener.Agent.Name, linked, + snapshot.OverrideOf(dialer.Agent.Id.Value, listener.Agent.Id.Value), state == "connected", dialer.RuntimeStatus, listener.RuntimeStatus, + state, reason, (inBackoff ? health!.Control.LastError : health?.Task.Quic.LastError)?.ToString(), + health is null ? 0 : inBackoff ? health.Control.RetryAttempt : health.Task.Quic.RetryAttempt, + health is null ? null : inBackoff ? health.Control.RetryInMs : health.Task.Quic.RetryInMs); } } diff --git a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs index 03ac1db..fc9a3c7 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs @@ -94,7 +94,7 @@ public static class SystemEndpoints if (result.IsSuccess && changes.ContainsKey(SettingsService.AutoLink)) { - await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 自动连接开关变了:重新下发各 Probe 的 Agent 列表和各 Agent 允许的 Probe + await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 自动连接开关变了:重新下发各 拨号方 的 Agent 列表和各 Agent 允许的 拨号方 } return result.TryGet(out IReadOnlyList? views, out FleetError? error) diff --git a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs index c5b4cc2..da9bad9 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs @@ -117,7 +117,7 @@ public static class TaskEndpoints return Problems.From(http, lineError); } - Result result = await tests.StartAsync(line.Key.Agent, line.Key.Probe, new StartTestRequest(request.Plan ?? "", request.Params), "api", line.IpVersion, ct); + Result result = await tests.StartAsync(line.Key.Listener, line.Key.Dialer, new StartTestRequest(request.Plan ?? "", request.Params), "api", line.IpVersion, ct); if (!result.TryGet(out TestRecord? test, out FleetError? error)) { return Problems.From(http, error); @@ -129,14 +129,14 @@ public static class TaskEndpoints private static async Task ListTestsAsync(HttpContext http, TestService tests, LineService lines, string line_id, int? limit, CancellationToken ct) { - // 只给 Agent id(并且有多个 Probe)时列出这个 Agent 上所有 Probe 的测试 + // 只给监听方 id(并且有多条线路)时列出这个节点上所有线路的测试 NodeId agent; - ProbeId? probe = null; + NodeId? dialer = null; string? ipVersion = null; if (LineKey.TryParse(line_id, out LineKey key)) { - agent = key.Agent; - probe = key.Probe; + agent = key.Listener; + dialer = key.Dialer; ipVersion = key.IpVersion; } else @@ -145,7 +145,7 @@ public static class TaskEndpoints } return TypedResults.Json( - new PageDto([.. (await tests.ListAsync(agent, probe, Math.Clamp(limit ?? 50, 1, 500), ipVersion, ct)).Select(Mappers.ToDto)], null), + new PageDto([.. (await tests.ListAsync(agent, dialer, Math.Clamp(limit ?? 50, 1, 500), ipVersion, ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoTestDto); } diff --git a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs index 3af6294..7af21a8 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs @@ -39,7 +39,7 @@ public static class TrustEndpoints /// 面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。 private static async Task MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct) { - foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole }) + foreach (string component in new[] { ReleaseService.AgentComponent }) { if (await releases.GetAsync(component, ct) is not { } offer) { diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index ac419d0..ab7d070 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -30,43 +30,51 @@ public static class Mappers public static OverviewLineDto ToDto(OverviewLine line) => new( - line.Line.Key.Id, line.Line.Agent.Agent.Id.Value, line.Line.Probe.Id, line.Line.Probe.Name, line.Line.Agent.Agent.Name, line.Line.Agent.Agent.Labels, - line.Line.Agent.Online, line.Line.Connected, line.Line.Agent.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion, ToDto(line.Line.QuicHealth)); + line.Line.Key.Id, line.Line.Listener.Agent.Id.Value, line.Line.Dialer.Id.Value, line.Line.Dialer.Name, line.Line.Listener.Agent.Name, line.Line.Listener.Agent.Labels, + line.Line.Listener.Online, line.Line.Connected, line.Line.Listener.EndpointChanges, ToDto(line.Summary), Summary(line.LastTest), line.Line.IpVersion, ToDto(line.Line.Link)); - public static QuicLineHealthDto? ToDto(ProbeQuicLinkSnapshot? snapshot) + public static QuicLineHealthDto? ToDto(ReportedLink? snapshot) { if (snapshot is null) { return null; } - ProbeQuicLinkHealth health = snapshot.Health; - double? Rate(long count) => health.HandshakeAttempts > 0 ? count * 100.0 / health.HandshakeAttempts : null; + LinkHealth link = snapshot.Health; + QuicHealth quic = link.Task.Quic; + double? Rate(long count) => quic.HandshakeAttempts > 0 ? count * 100.0 / quic.HandshakeAttempts : null; return new QuicLineHealthDto( - health.Connected, health.HandshakeAttempts, health.HandshakeSuccesses, health.HandshakeFailures, health.HandshakeTimeouts, - Rate(health.HandshakeSuccesses), Rate(health.HandshakeFailures), Rate(health.HandshakeTimeouts), health.HandshakeP95Ms, health.HandshakeSampleCount, - health.TransportPacketLossRate, health.TransportPacketsSent, health.TransportCongestionEvents, - health.TransportEcnCongestionEvents, health.PathMtu, Time(snapshot.AtMs), health.State switch + link.Ready, quic.HandshakeAttempts, quic.HandshakeSuccesses, quic.HandshakeFailures, quic.HandshakeTimeouts, + Rate(quic.HandshakeSuccesses), Rate(quic.HandshakeFailures), Rate(quic.HandshakeTimeouts), quic.HandshakeP95Ms, quic.HandshakeSampleCount, + quic.TransportPacketLossRate, quic.TransportPacketsSent, quic.TransportCongestionEvents, + quic.TransportEcnCongestionEvents, quic.PathMtu, Time(snapshot.AtMs), quic.State switch { - ProbeLinkAttemptState.Connected => "connected", - ProbeLinkAttemptState.RetryWait => "retry_wait", + QuicState.Ready => "connected", + QuicState.Retrying => "retry_wait", + QuicState.Idle => "idle", _ => "connecting", - }, health.RetryAttempt, health.RetryInMs); + }, quic.RetryAttempt, quic.RetryInMs, ControlStateName(link.Control.State)); } + private static string ControlStateName(ControlState state) => state switch + { + ControlState.Up => "up", + ControlState.Backoff => "backoff", + ControlState.Syncing => "syncing", + ControlState.Authenticating => "authenticating", + _ => "dialing", + }; + public static HostDto ToDto(HostSnapshot h) => new(h.CpuPercent, h.MemUsed, h.MemTotal, h.DiskUsed, h.DiskTotal, h.NetRxTotal, h.NetTxTotal, h.NetRxBps, h.NetTxBps, h.NetLinkMbps, h.UptimeSeconds, Time(h.AtUtcMs)); - public static ConnectedProbeDto ToDto(ConnectedProbeInfo p) => new(p.Id, p.Version, p.Remote, Time(p.SinceMs), p.IpVersion); + public static ConnectedDialerDto ToDto(ConnectedDialerInfo d) => new(d.Id, d.Version, d.Remote, Time(d.SinceMs), d.IpVersion); - public static ProbeDto ToDto(ProbeView v) => - new( - v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online, - [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], v.RuntimeStatus, ProbeIpv6Of(v.Probe), v.Probe.AddedBy); + public static OutgoingConnectionDto ToDto(OutgoingConnection c) => new(c.ListenerId.Value, c.ListenerName, c.ListenerOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion); public static SeriesDto ToDto(SeriesResult r, bool live) => new( - r.Line.Id, r.Line.Agent.Value, r.Line.Probe.Value, Time(r.Range.FromMs), Time(r.Range.ToMs), Steps.Format(r.Range.StepMs), r.Plan.Name, live && r.Range.IsRelative, r.Plan.Degraded, + r.Line.Id, r.Line.Listener.Value, r.Line.Dialer.Value, Time(r.Range.FromMs), Time(r.Range.ToMs), Steps.Format(r.Range.StepMs), r.Plan.Name, live && r.Range.IsRelative, r.Plan.Degraded, r.T, r.Values, ToDto(r.Summary), new EdgePartialDto(r.Range.FirstPartial, r.Range.LastPartial), [.. r.Gaps.Select(g => new GapDto(Time(g.FromMs), Time(g.ToMs), g.Reason))], [.. r.Events.Select(ToDto)], r.Versions, r.Line.IpVersion); @@ -74,12 +82,12 @@ public static class Mappers { TestRecord t = detail.Test; return new TestDto( - t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, detail.PhaseKinds, t.Status, t.Phase, t.ErrorCode, t.Trigger, + t.Id.Value, t.AgentId.Value, t.DialerId.Value, t.Plan, detail.PhaseKinds, t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), detail.Result is null ? null : ToDto(detail.Result), t.IpVersion); } public static TestDto ToDto(TestRecord t) => - new(t.Id.Value, t.AgentId.Value, t.ProbeId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null, t.IpVersion); + new(t.Id.Value, t.AgentId.Value, t.DialerId.Value, t.Plan, [.. (Plans.Find(t.Plan)?.Phases ?? []).Select(p => p.Kind)], t.Status, t.Phase, t.ErrorCode, t.Trigger, Time(t.RequestedAtMs), Time(t.StartedAtMs), Time(t.FinishedAtMs), null, t.IpVersion); public static TestResultDto ToDto(TestResultView r) => new( @@ -120,10 +128,10 @@ public static class Mappers } HeartbeatDto? heartbeat = view.Heartbeat is { } h - ? new HeartbeatDto(h.Sessions, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0)) + ? new HeartbeatDto(h.Dialers.Count, h.PpsIn, h.PpsOut, h.DroppedInvalid, h.DroppedRateLimited, h.QueueBytes, Time(view.HeartbeatAtMs ?? 0)) : null; return new AgentDto( - a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.ProbeUdpPorts, a.Labels, a.PublicVisible, + a.Id.Value, a.Name, StatusName(a.Status), view.Online, a.Listens, view.DialerConnected, [.. view.Dialers.Select(ToDto)], [.. view.Outgoing.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.TcpPort, a.QuicPorts, a.Labels, a.PublicVisible, a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, view.RuntimeStatus, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy); } @@ -153,29 +161,11 @@ public static class Mappers diagnostic?.HasGlobalAddress, diagnostic?.HasDefaultRoute, state, diagnostic?.NetworkScope ?? "unknown", diagnostic?.HostHasGlobalAddress, diagnostic?.HostHasDefaultRoute); } - private static Ipv6DiagnosticDto? ProbeIpv6Of(ProbeRecord probe) - { - if (probe.Ipv6DiagnosticsJson is not { } json) - { - return null; - } - - try - { - Ipv6Diagnostic? d = JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.Ipv6Diagnostic); - return d is null ? null : new Ipv6DiagnosticDto(null, d.SocketSupported, null, d.HasGlobalAddress, d.HasDefaultRoute, d.State, d.NetworkScope, d.HostHasGlobalAddress, d.HostHasDefaultRoute); - } - catch (JsonException) - { - return null; - } - } - public static TaskDto ToDto(TaskRecord t) => new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs)); public static JoinTokenDto ToDto(JoinTokenInfo t) => - new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); + new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), t.CreatedBy); public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]); @@ -224,8 +214,8 @@ public static class Mappers private static string? RoleName(ColumnRole role) => role switch { - ColumnRole.TsProbe => "ts_probe", - ColumnRole.TsAgent => "ts_agent", + ColumnRole.TsDialer => "ts_dialer", + ColumnRole.TsListener => "ts_listener", ColumnRole.TsLocal => "ts_local", _ => null, }; diff --git a/backend/src/MoShiro.Backend.Api/Common/Problems.cs b/backend/src/MoShiro.Backend.Api/Common/Problems.cs index 87576e9..ae7f190 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Problems.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Problems.cs @@ -48,13 +48,13 @@ public static class ErrorCodeHttpMap Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized); Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid); Add( - 404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound, + 404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound, MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound); - Add(409, MoShiroErrors.NameConflict, MoShiroErrors.ProbeRevoked, MoShiroErrors.ProbeOffline, MoShiroErrors.TestBusy, MoShiroErrors.AgentNotActive, MoShiroErrors.AgentOffline, FleetErrors.NameConflict, FleetErrors.NodeOffline); + Add(409, MoShiroErrors.NameConflict, MoShiroErrors.NodeRevoked, MoShiroErrors.DialerOffline, MoShiroErrors.NotListening, MoShiroErrors.TestBusy, MoShiroErrors.AgentNotActive, MoShiroErrors.AgentOffline, FleetErrors.NameConflict, FleetErrors.NodeOffline); Add(410, MoShiroErrors.RawExpired, MoShiroErrors.JoinTokenExpired, FleetErrors.JoinTokenExpired); Add(412, MoShiroErrors.PreconditionFailed); Add(413, MoShiroErrors.PayloadTooLarge); - Add(422, MoShiroErrors.ProbeRequired, MoShiroErrors.RangeTooLarge, MoShiroErrors.InvalidStep, MoShiroErrors.InvalidRange, MoShiroErrors.UnknownMetric, MoShiroErrors.UnknownPreset, MoShiroErrors.TooManyMetrics, FleetErrors.Unsupported); + Add(422, MoShiroErrors.DialerRequired, MoShiroErrors.SelfLink, MoShiroErrors.RangeTooLarge, MoShiroErrors.InvalidStep, MoShiroErrors.InvalidRange, MoShiroErrors.UnknownMetric, MoShiroErrors.UnknownPreset, MoShiroErrors.TooManyMetrics, FleetErrors.Unsupported); Add(429, MoShiroErrors.TooManyRequests, MoShiroErrors.TooManyStreams); Add(503, MoShiroErrors.StorageUnavailable, FleetErrors.Timeout); map[NotImplemented.Value] = 501; diff --git a/backend/src/MoShiro.Backend.Api/Common/QueryHelpers.cs b/backend/src/MoShiro.Backend.Api/Common/QueryHelpers.cs index b4dd594..ac5e76d 100644 --- a/backend/src/MoShiro.Backend.Api/Common/QueryHelpers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/QueryHelpers.cs @@ -73,7 +73,7 @@ internal static class QueryHelpers return false; } - /// 线路 id(agent~probe,或只有一个 Probe 时的 Agent id)→ 可以展示的线路。否则 404 / 422(不暴露被隐藏的线路存在与否)。 + /// 线路 id(listener~dialer,或只有一条线路时的监听方 id)→ 可以展示的线路。否则 404 / 422(不暴露被隐藏的线路存在与否)。 public static async ValueTask> FindVisibleLineAsync(HttpContext http, LineService lines, string id, CancellationToken ct) { Result found = await lines.ResolveAsync(id, ct); @@ -82,7 +82,7 @@ internal static class QueryHelpers return Result.Fail(error); } - return line.Agent.Agent.Status == Fleet.Server.NodeStatus.Active && (line.Agent.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)) && line.Probe.RevokedAtMs is null + return line.Listener.Agent.Status == Fleet.Server.NodeStatus.Active && (line.Listener.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)) && line.Dialer.Status == Fleet.Server.NodeStatus.Active ? found : Result.Fail(MoShiroErrors.AgentNotFound, "line not found"); } diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index 74dcb82..0e0aa5c 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -41,13 +41,14 @@ public sealed record AgentDto( string Name, string Status, bool Online, - bool ProbeConnected, - IReadOnlyList Probes, + bool Listens, + bool DialerConnected, + IReadOnlyList Dialers, + IReadOnlyList Outgoing, HostDto? Host, string PublicHost, - int ProbeTcpPort, - IReadOnlyList ProbeUdpPorts, - IReadOnlyList ProbeQuicPorts, + int TcpPort, + IReadOnlyList QuicPorts, IReadOnlyList Labels, bool PublicVisible, IReadOnlyList Kinds, @@ -73,10 +74,10 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo /// merge-patch:缺省或为 null 的字段不修改。 /// true:撤销手填,回到自动识别(ipv6_host_auto 同理)。手填 public_host 或 ipv6_host 就是手动,自动识别不再覆盖。 -public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null); +/// telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。 +public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null, string? Isp = null); -/// agent 或 probe:用这个令牌注册出来的是哪种节点。 -public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null); +public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string? CreatedBy = null); /// 属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。 public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList? Controllers = null); @@ -123,24 +124,25 @@ public sealed record TrustStateDto(bool Initialized, string? Genesis, int Versio public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose, QuadletInstallDto Quadlet); -/// Agent 眼中连着的一个 Probe(Agent 上报)。 -public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); +/// 监听方眼中连着的一个拨号方(监听方上报)。 +public sealed record ConnectedDialerDto(string DialerId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); -/// 某个 Probe 现在连着的一个 Agent。 -public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); +/// 这个节点现在连着的一个监听节点(监听方上报)。 +public sealed record OutgoingConnectionDto(string ListenerId, string ListenerName, bool ListenerOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); -public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections, NodeRuntimeStatus RuntimeStatus, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null); +/// +/// 一对(拨号方,监听方)的线路开关:linked 是最终结果,override 是手动设置(null = 跟随自动连接),connected 是现在有没有连着。 +/// connection_state:disabled、dialer_unavailable、listener_unavailable、waiting_report、control_connecting、control_down(失联)、task_connecting、task_retrying、connected。 +/// +public sealed record LinkDto(string DialerId, string DialerName, string ListenerId, string ListenerName, bool Linked, bool? Override, bool Connected, NodeRuntimeStatus DialerRuntimeStatus, NodeRuntimeStatus ListenerRuntimeStatus, string ConnectionState = "control_connecting", string? ConnectionReason = null, string? LastError = null, int RetryAttempt = 0, long? RetryInMs = null); -/// 一对(Probe,Agent)的线路开关:linked 是最终结果,override 是手动设置(null = 跟随自动连接),connected 是现在有没有连着。 -public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected, NodeRuntimeStatus ProbeRuntimeStatus, NodeRuntimeStatus AgentRuntimeStatus, string ConnectionState = "connecting", string? ConnectionReason = null, string? LastError = null, int RetryAttempt = 0, long? RetryInMs = null); +/// 线路目录里的一个节点:listens 表示它可以作为监听方(被拖到的目标);任何激活的节点都可以作为拨号方。 +public sealed record LinkNodeDto(string Id, string Name, bool Listens, bool Online, NodeRuntimeStatus RuntimeStatus); -public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList Items); +public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList Nodes, IReadOnlyList Items); public sealed record SetLinkRequest(bool? Enabled); -/// Isp:telecom / unicom / mobile / broadnet / other 是手动指定,auto 是回到自动识别;不传就不改。 -public sealed record PatchProbeRequest(string? Name, string? Isp = null); - public sealed record TaskDto( string Id, string Scope, @@ -210,7 +212,7 @@ public sealed record RecomputeDto(string State, IReadOnlyList Metrics, l /// 节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。 public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account); -public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, IReadOnlyList? Owners = null); +public sealed record NodeBuildDto(string Id, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, IReadOnlyList? Owners = null); /// 节点宿主机上的更新器报告的状态(up_to_date、available、applying、failed、rolled_back、invalid);没装更新器的节点没有这个字段。 public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 1bdd0ed..0967ec6 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -62,12 +62,9 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateJoinTokenRequest))] [JsonSerializable(typeof(CreatedJoinTokenDto))] -[JsonSerializable(typeof(PatchProbeRequest))] -[JsonSerializable(typeof(ProbeDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(HostSeriesDto))] [JsonSerializable(typeof(LiveHostDto))] -[JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(TaskDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateTaskRequest))] diff --git a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs index 8dea47f..1c91b2a 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs @@ -19,19 +19,19 @@ public sealed record TestSummaryDto(string Id, string Plan, string Status, DateT public sealed record OverviewLineDto( string Id, string AgentId, - string ProbeId, - string ProbeName, + string DialerId, + string DialerName, string Name, IReadOnlyList Labels, bool Online, - bool ProbeConnected, + bool DialerConnected, int EndpointChanges, SummaryDto Summary, TestSummaryDto? LastTest, string IpVersion = "ipv4", QuicLineHealthDto? QuicHealth = null); -/// Latest QUIC session health reported by the Probe; cumulative counters reset when the Probe restarts. +/// Latest QUIC data-channel health reported by the dialing node, plus the control-layer state it runs on; cumulative counters reset when that node restarts. public sealed record QuicLineHealthDto( bool Connected, long HandshakeAttempts, @@ -51,7 +51,8 @@ public sealed record QuicLineHealthDto( DateTimeOffset At, string State = "connecting", int RetryAttempt = 0, - long? RetryInMs = null); + long? RetryInMs = null, + string ControlState = "dialing"); public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffset To, string Step, string Source, bool Degraded, IReadOnlyList Lines); @@ -59,7 +60,7 @@ public sealed record OverviewDto(string Range, DateTimeOffset From, DateTimeOffs public sealed record SeriesDto( string LineId, string AgentId, - string ProbeId, + string DialerId, DateTimeOffset From, DateTimeOffset To, string Step, @@ -99,16 +100,16 @@ public sealed record HeatmapDto( IReadOnlyList Edges, IReadOnlyList> Counts); -/// 一条线路 = 一对(Agent,Probe);id 是 <agent_id>~<probe_id>。 +/// 一条线路 = 一对(监听方,拨号方);agent_id 是监听方,id 是 <agent_id>~<dialer_id>。 public sealed record LineDto( string Id, string AgentId, - string ProbeId, - string ProbeName, + string DialerId, + string DialerName, string Name, IReadOnlyList Labels, bool Online, - bool ProbeConnected, + bool DialerConnected, DateTimeOffset? DataSince, IReadOnlyList Kinds, TestSummaryDto? LastTest, @@ -132,7 +133,7 @@ public sealed record TestResultDto( public sealed record TestDto( string Id, string AgentId, - string ProbeId, + string DialerId, string Plan, IReadOnlyList Phases, string Status, @@ -201,9 +202,9 @@ public sealed record HostDto( long? UptimeSeconds, DateTimeOffset At); -/// 一个节点(Agent 或 Probe)的最新主机状态;stale 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。 +/// 一个节点的最新主机状态;stale 表示超过 15 秒没有更新(离线,或者节点关闭了上报)。 /// 节点的展示信息:只有系统、架构、国家代码、运营商,没有任何地址;站点打开「显示构建信息」之后还有版本和 tag。 -public sealed record NodeInfoDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false); +public sealed record NodeInfoDto(string Id, string? Os, string? OsName, string? Arch, string? Country, string? IspKey, string? IspName, string? Version = null, string? Tag = null, bool IspManual = false); /// 一个程序的构建信息:版本、tag、提交(短)和构建时间(UTC,ISO 8601)。没设置的是 null。 public sealed record BuildDto(string Version, string? Tag, string? Commit, string? BuiltAt); @@ -211,10 +212,9 @@ public sealed record BuildDto(string Version, string? Tag, string? Commit, strin /// 公开的「关于」:站点没有打开「显示构建信息」时只有 show_build: false。 public sealed record AboutDto(bool ShowBuild, BuildDto? Backend); -/// agent 或 probe。 -public sealed record HostItemDto(string Id, string Kind, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host); +public sealed record HostItemDto(string Id, string Name, bool Online, bool Stale, DateTimeOffset? LastSeenAt, HostDto? Host); -public sealed record LiveHostDto(string Id, string Kind, bool Online, HostDto Host); +public sealed record LiveHostDto(string Id, bool Online, HostDto Host); /// 主机资源占用的历史(列式,按步长取平均)。 public sealed record HostSeriesDto(string Id, DateTimeOffset From, DateTimeOffset To, string Step, IReadOnlyList T, IReadOnlyDictionary Values); diff --git a/backend/src/MoShiro.Backend.Api/InstallScripts.cs b/backend/src/MoShiro.Backend.Api/InstallScripts.cs index cbfe5a6..0c6aea8 100644 --- a/backend/src/MoShiro.Backend.Api/InstallScripts.cs +++ b/backend/src/MoShiro.Backend.Api/InstallScripts.cs @@ -19,17 +19,17 @@ public static class InstallScripts public static string Script(string resource) => Read(resource); - /// compose 文件的资源名(agent.yaml、probe.yaml、backend.yaml 对应的);不是这三个就是 null。 - public static string? ComposeResource(string file) => file is "agent.yaml" or "probe.yaml" or "backend.yaml" ? "compose-" + file : null; + /// compose 文件的资源名(agent.yaml、backend.yaml 对应的);不是这三个就是 null。 + public static string? ComposeResource(string file) => file is "agent.yaml" or "backend.yaml" ? "compose-" + file : null; - /// 某个角色(agent、probe)的 compose 文件的 SHA-256;其他角色返回空。 - public static string ComposeSha256(string? role) => role is "agent" or "probe" ? Sha256("compose-" + role + ".yaml") : string.Empty; + /// 某个角色(agent)的 compose 文件的 SHA-256;其他角色返回空。 + public static string ComposeSha256(string? role) => role is "agent" ? Sha256("compose-" + role + ".yaml") : string.Empty; - /// Quadlet 单元的资源名(agent.container、probe.container);不是这两个就是 null。 - public static string? QuadletResource(string file) => file is "agent.container" or "probe.container" ? "quadlet-" + file : null; + /// Quadlet 单元的资源名(agent.container);不是这两个就是 null。 + public static string? QuadletResource(string file) => file is "agent.container" ? "quadlet-" + file : null; - /// 某个角色(agent、probe)的 Quadlet 单元的 SHA-256;其他角色返回空。 - public static string QuadletSha256(string? role) => role is "agent" or "probe" ? Sha256("quadlet-" + role + ".container") : string.Empty; + /// 某个角色(agent)的 Quadlet 单元的 SHA-256;其他角色返回空。 + public static string QuadletSha256(string? role) => role is "agent" ? Sha256("quadlet-" + role + ".container") : string.Empty; /// 脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。 public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource)))); diff --git a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj index d636ca1..72908ea 100644 --- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj +++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj @@ -12,10 +12,8 @@ - - diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index 9fdaee6..a934b73 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -22,7 +22,7 @@ public static class OpsEndpoints // 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。 // 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。 app.MapGet("/install/openwrt.sh", (HttpContext http) => Static(http, "openwrt.sh", "text/x-shellscript")) - .Api("install_openwrt", "The static native OpenWrt Probe installer and updater", Tag) + .Api("install_openwrt", "The static native OpenWrt installer and updater (Agent)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript"); app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript")) @@ -31,30 +31,29 @@ public static class OpsEndpoints app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript")) .ExcludeFromDescription(); // 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。 - app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false)) - .Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag) + app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, string? code) => ManifestAsync(http, tokens, releases, code, signature: false)) + .Api("install_manifest", "The signed release manifest an install code may fetch (exact signed bytes)", Tag) .Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404); - app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true)) + app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, string? code) => ManifestAsync(http, tokens, releases, code, signature: true)) .Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); - // compose 文件:Agent、Probe、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值), + // compose 文件:Agent、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值), // 面板的命令把参数放在本地的 .env 里,下载后先核对 SHA-256。 app.MapGet("/install/compose/{file}", (HttpContext http, string file) => InstallScripts.ComposeResource(file) is { } resource ? Static(http, resource, "text/yaml") : Problems.Create(http, MoShiroErrors.NotFound, "unknown compose file")) - .Api("install_compose", "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", Tag) + .Api("install_compose", "The static compose file of the agent or the backend (agent.yaml, backend.yaml; same bytes for everyone)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/yaml").ProducesProblems(404); - // Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):Agent、Probe 各一份,静态,命令里先核对 SHA-256。 + // Podman Quadlet 单元(Linux 上推荐,节点的更新靠 Pull=always):统一 Agent 单元,静态,命令里先核对 SHA-256。 app.MapGet("/install/quadlet/{file}", (HttpContext http, string file) => InstallScripts.QuadletResource(file) is { } resource ? Static(http, resource, "text/plain") : Problems.Create(http, MoShiroErrors.NotFound, "unknown quadlet file")) - .Api("install_quadlet", "The static Podman Quadlet unit of agent or probe (agent.container, probe.container; same bytes for everyone)", Tag) + .Api("install_quadlet", "The static Podman Quadlet unit of the agent (agent.container; same bytes for everyone)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain")) .Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain"); } - private static async Task ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature) + private static async Task ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, string? code, bool signature) { - string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted); - ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted); + ReleaseOffer? offer = !string.IsNullOrEmpty(code) && await tokens.IsUsableAsync(code, http.RequestAborted) ? await releases.GetOrFetchAsync(ReleaseService.AgentComponent, http.RequestAborted) : null; if (offer is null) { return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code"); diff --git a/backend/src/MoShiro.Backend.Api/Public/HostEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/HostEndpoints.cs index 6147243..168cbac 100644 --- a/backend/src/MoShiro.Backend.Api/Public/HostEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/HostEndpoints.cs @@ -8,26 +8,24 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Api; /// -/// Agent 和 Probe 的主机资源占用:最新值(实时的部分走 SSE 的 hosts 主题)和历史曲线。只有汇总的数字,没有型号和 IP。 -/// 和别的公开接口一样:Agent 看得见才给(隐藏的只有登录后才看得见),Probe 没被吊销就给。 +/// 节点的主机资源占用:最新值(实时的部分走 SSE 的 hosts 主题)和历史曲线。只有汇总的数字,没有型号和 IP。 +/// 和别的公开接口一样:节点看得见才给(隐藏的只有登录后才看得见)。 /// public static class HostEndpoints { private const string Tag = "public"; - private const string AgentKind = "agent"; - private const string ProbeKind = "probe"; public static void Map(RouteGroupBuilder group) { group.MapGet("/hosts", ListAsync) - .Api("hosts_list", "Latest host resource usage of every visible agent and probe (no hardware model, no addresses)", Tag) + .Api("hosts_list", "Latest host resource usage of every visible node (no hardware model, no addresses)", Tag) .Returns>().ProducesProblems(403); group.MapGet("/hosts/{node_id}", HistoryAsync) - .Api("hosts_history", "Host resource usage history of one agent or probe (columnar, averaged per step)", Tag) + .Api("hosts_history", "Host resource usage history of one node (columnar, averaged per step)", Tag) .Returns().ProducesProblems(403, 404, 422); } - private static async Task ListAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, CancellationToken ct) + private static async Task ListAsync(HttpContext http, AgentService agents, HostStatusService hosts, CancellationToken ct) { var items = new List(); foreach (AgentView view in await agents.ListAsync(ct)) @@ -38,37 +36,16 @@ public static class HostEndpoints } (HostSnapshot? host, bool stale) = hosts.Latest(view.Agent.Id); - items.Add(new HostItemDto(view.Agent.Id.Value, AgentKind, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host))); - } - - foreach (ProbeView view in await probes.ListAsync(ct)) - { - if (view.Revoked) - { - continue; - } - - (HostSnapshot? host, bool stale) = hosts.Latest(new NodeId(view.Probe.Id)); - items.Add(new HostItemDto(view.Probe.Id, ProbeKind, view.Probe.Name, view.Online, stale, host is null ? null : Mappers.Time(host.AtUtcMs), host is null ? null : Mappers.ToDto(host))); + items.Add(new HostItemDto(view.Agent.Id.Value, view.Agent.Name, view.Online, stale, Mappers.Time(view.Agent.LastSeenAtMs), host is null ? null : Mappers.ToDto(host))); } return TypedResults.Json(new PageDto(items, null), ApiJsonContext.Default.PageDtoHostItemDto); } - private static async Task HistoryAsync(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct) + private static async Task HistoryAsync(HttpContext http, AgentService agents, HostStatusService hosts, TimeProvider time, string node_id, string? range, CancellationToken ct) { - bool visible; - if (node_id.StartsWith(AgentIds.Prefix, StringComparison.Ordinal)) - { - AgentView? view = await agents.GetAsync(new NodeId(node_id), ct); - visible = view is { Agent.Status: Fleet.Server.NodeStatus.Active } && (view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http)); - } - else - { - visible = node_id.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) && await probes.GetAsync(node_id, ct) is { Revoked: false }; - } - - if (!visible) + AgentView? view = await agents.GetAsync(new NodeId(node_id), ct); + if (view is not { Agent.Status: Fleet.Server.NodeStatus.Active } || !(view.Agent.PublicVisible || PublicGuard.CanSeeHidden(http))) { return Problems.Create(http, MoShiroErrors.AgentNotFound, "node not found"); } diff --git a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs index 055de02..89d61c1 100644 --- a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs @@ -32,7 +32,7 @@ public static class LiveEndpoints } private static async Task StreamAsync( - HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter, + HttpContext http, AgentService agents, HostStatusService hosts, LineService lineService, TestService tests, QueryService query, LiveHub hub, SseLimiter limiter, BackendOptions options, BackendCounters counters, TimeProvider time, IHostApplicationLifetime lifetime, string? ip_version) { string ipVersion = ip_version ?? IpVersions.V4; @@ -84,7 +84,7 @@ public static class LiveEndpoints http.Response.Headers["X-Accel-Buffering"] = "no"; await http.Response.Body.FlushAsync(http.RequestAborted); - var session = new LiveSession(http, agents, probes, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion); + var session = new LiveSession(http, agents, hosts, lineService, tests, query, hub, options, time, topics, lineKeys, ipVersion); using var linked = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping); await session.RunAsync(linked.Token); } @@ -105,7 +105,6 @@ public static class LiveEndpoints { private readonly HttpContext _http; private readonly AgentService _agents; - private readonly ProbeService _probes; private readonly HostStatusService _hosts; private readonly LineService _lines; private readonly IReadOnlyDictionary _lineKeys; @@ -126,11 +125,10 @@ public static class LiveEndpoints private long _lastWriteMs; private long _lastPeriodicMs; - public LiveSession(HttpContext http, AgentService agents, ProbeService probes, HostStatusService hosts, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList topics, IReadOnlyDictionary lineKeys, string ipVersion) + public LiveSession(HttpContext http, AgentService agents, HostStatusService hosts, LineService lines, TestService tests, QueryService query, LiveHub hub, BackendOptions options, TimeProvider time, IReadOnlyList topics, IReadOnlyDictionary lineKeys, string ipVersion) { _http = http; _agents = agents; - _probes = probes; _hosts = hosts; _lines = lines; _lineKeys = lineKeys; @@ -220,20 +218,10 @@ public static class LiveEndpoints foreach (HostSignal host in hostSignals) { - if (host.Node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal)) - { - if (await _probes.GetAsync(host.Node.Value, ct) is { Revoked: false } probe) - { - await WriteAsync("host", new LiveHostDto(host.Node.Value, "probe", probe.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct); - } - - continue; - } - AgentView? view = await _agents.GetAsync(host.Node, ct); if (view is not null && IsVisible(view) && view.Agent.Status == Fleet.Server.NodeStatus.Active) { - await WriteAsync("host", new LiveHostDto(host.Node.Value, "agent", view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct); + await WriteAsync("host", new LiveHostDto(host.Node.Value, view.Online, Mappers.ToDto(host.Host)), ApiJsonContext.Default.LiveHostDto, ct); } } @@ -253,7 +241,7 @@ public static class LiveEndpoints if (dirtyLines.Length > 0) { - foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Series && dirtyLines.Contains((_lineKeys[t.Raw].Agent, _lineKeys[t.Raw].Series)))) + foreach (LiveTopic topic in _topics.Where(t => t.Kind == LiveTopicKind.Series && dirtyLines.Contains((_lineKeys[t.Raw].Listener, _lineKeys[t.Raw].Series)))) { await PushSeriesAsync(topic, ct); } @@ -298,15 +286,7 @@ public static class LiveEndpoints case LiveTopicKind.Hosts: foreach (AgentView view in (await _agents.ListAsync(ct)).Where(v => v.Agent.Status == Fleet.Server.NodeStatus.Active && IsVisible(v) && v.Host is not null)) { - await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, "agent", view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct); - } - - foreach (ProbeView view in await _probes.ListAsync(ct)) - { - if (!view.Revoked && _hosts.Latest(new NodeId(view.Probe.Id)).Host is { } host) - { - await WriteAsync("host", new LiveHostDto(view.Probe.Id, "probe", view.Online, Mappers.ToDto(host)), ApiJsonContext.Default.LiveHostDto, ct); - } + await WriteAsync("host", new LiveHostDto(view.Agent.Id.Value, view.Online, Mappers.ToDto(view.Host!)), ApiJsonContext.Default.LiveHostDto, ct); } break; diff --git a/backend/src/MoShiro.Backend.Api/Public/NodeEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/NodeEndpoints.cs index f5db601..bbe64f7 100644 --- a/backend/src/MoShiro.Backend.Api/Public/NodeEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/NodeEndpoints.cs @@ -16,7 +16,7 @@ public static class NodeEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/nodes", ListAsync) - .Api("nodes_list", "Display info of the visible agents and probes: system, architecture, country and ISP (never an address)", Tag) + .Api("nodes_list", "Display info of the visible nodes: system, architecture, country and ISP (never an address)", Tag) .Returns>().ProducesProblems(403); group.MapGet("/about", AboutAsync) .Api("about_get", "The backend's version, tag and build time, if the site shows build info", Tag) @@ -36,9 +36,9 @@ public static class NodeEndpoints return TypedResults.Json(new AboutDto(show, show ? Backend(withCommit: false) : null), ApiJsonContext.Default.AboutDto); } - private static async Task ListAsync(HttpContext http, AgentService agents, ProbeService probes, NodeInfoService nodes, SettingsService settings, CancellationToken ct) + private static async Task ListAsync(HttpContext http, AgentService agents, NodeInfoService nodes, SettingsService settings, CancellationToken ct) { - // 和别的公开接口一样:看得见的 Agent(隐藏的只有登录后才看得见)和没有被吊销的 Probe + // 和别的公开接口一样:看得见的节点(隐藏的只有登录后才看得见) var visible = new HashSet(StringComparer.Ordinal); foreach (AgentView view in await agents.ListAsync(ct)) { @@ -48,20 +48,12 @@ public static class NodeEndpoints } } - foreach (ProbeView probe in await probes.ListAsync(ct)) - { - if (!probe.Revoked) - { - visible.Add(probe.Probe.Id); - } - } - // 版本和 tag 只在站点打开「显示构建信息」时才给访客;关着就是 null bool showBuild = await settings.IsShowBuildAsync(ct); var items = (await nodes.ListAsync(ct)) .Where(n => visible.Contains(n.NodeId)) .OrderBy(n => n.NodeId, StringComparer.Ordinal) - .Select(n => new NodeInfoDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null, n.IspManual)) + .Select(n => new NodeInfoDto(n.NodeId, n.Os, n.OsName, n.Arch, n.Country, n.IspKey, n.IspName, showBuild ? n.Version : null, showBuild ? n.Tag : null, n.IspManual)) .ToList(); http.Response.Headers.CacheControl = "no-store"; return TypedResults.Json(new PageDto(items, null), ApiJsonContext.Default.PageDtoNodeInfoDto); diff --git a/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs index 4de3589..7c4ca9e 100644 --- a/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/QueryEndpoints.cs @@ -96,13 +96,13 @@ public static class QueryEndpoints return Problems.From(http, error); } - AgentView view = line.Agent; - IReadOnlyList recent = await tests.ListAsync(view.Agent.Id, new ProbeId(line.Probe.Id), 1, line.IpVersion, ct); + AgentView view = line.Listener; + IReadOnlyList recent = await tests.ListAsync(view.Agent.Id, line.Dialer.Id, 1, line.IpVersion, ct); TestRecord? last = recent.Count > 0 ? recent[0] : null; return TypedResults.Json( new LineDto( - line.Key.Id, view.Agent.Id.Value, line.Probe.Id, line.Probe.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected, - Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.QuicHealth)), + line.Key.Id, view.Agent.Id.Value, line.Dialer.Id.Value, line.Dialer.Name, view.Agent.Name, view.Agent.Labels, view.Online, line.Connected, + Mappers.Time(await query.EarliestAsync(line.Key, ct)), view.Agent.Kinds, Mappers.Summary(last), line.IpVersion, view.Agent.Ipv6Host.Length > 0, Mappers.ToDto(line.Link)), ApiJsonContext.Default.LineDto); } @@ -244,7 +244,7 @@ public static class QueryEndpoints string? key = sort ?? selection.ScoreModelIds.Select(m => "score." + m).FirstOrDefault(); if (key is null) { - return lines.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal); + return lines.OrderBy(l => l.Line.Listener.Agent.Name, StringComparer.Ordinal); } bool descending = order is not null @@ -255,6 +255,6 @@ public static class QueryEndpoints IOrderedEnumerable ordered = descending ? withValue.OrderByDescending(l => l.Summary.Values[key]) : withValue.OrderBy(l => l.Summary.Values[key]); - return ordered.ThenBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal).Concat(without.OrderBy(l => l.Line.Agent.Agent.Name, StringComparer.Ordinal)); + return ordered.ThenBy(l => l.Line.Listener.Agent.Name, StringComparer.Ordinal).Concat(without.OrderBy(l => l.Line.Listener.Agent.Name, StringComparer.Ordinal)); } } diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index cc7699e..4d0fc68 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -4,10 +4,10 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; -/// agent(默认)或 probe:用这个令牌注册出来的是哪种节点。 +/// 节点安装角色,统一为 agent,默认监听并拨号。 public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null); -/// 各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。 +/// 各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名。 public sealed record InstallCommands(string Linux, string Macos, string Windows, string Openwrt = ""); /// @@ -20,7 +20,7 @@ public sealed record InstallPlan(string? Genesis, IReadOnlyList Controll /// /// compose 安装:Env 是要存成 .env 的内容(只有公开的值),Url 和 Sha256 是 compose 文件的下载地址和哈希, -/// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;Agent 的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。 +/// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;监听模式的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。 /// public sealed record ComposeInstall(string Env, string Url, string Sha256, string Linux, string Macos, string Windows); @@ -36,12 +36,6 @@ public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, Inst public sealed class JoinTokenService { public const string AgentRole = "agent"; - public const string ProbeRole = "probe"; - - /// 令牌里存的「种类」:空表示 Agent,其余就是角色名。 - public static string KindOf(string role) => role == ProbeRole ? ProbeRole : ""; - - public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind; private readonly IJoinTokenStore _store; private readonly BackendOptions _options; @@ -57,9 +51,9 @@ public sealed class JoinTokenService public async ValueTask> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct) { var errors = new Dictionary(); - if (request.Role is not (AgentRole or ProbeRole)) + if (request.Role != AgentRole) { - errors["role"] = "must be agent or probe"; + errors["role"] = "must be agent"; } if (request.MaxUses is < 1 or > 100_000) @@ -90,7 +84,7 @@ public sealed class JoinTokenService string plaintext = TokenHash.NewToken("jt"); long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); JoinTokenInfo info = await _store.CreateAsync( - Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role), + Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, "", request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct); return Result.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Quadlet(BaseUrl(requestBaseUrl), request.Role, plaintext, plan))); @@ -110,7 +104,7 @@ public sealed class JoinTokenService $"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"", $"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; " + $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)", - role == ProbeRole && plan.Genesis is not null + plan.Genesis is not null ? $"f=$(mktemp) && (curl -fsS '{baseUrl}/install/openwrt.sh' -o \"$f\" || uclient-fetch -q -O \"$f\" '{baseUrl}/install/openwrt.sh') && echo \"{plan.OpenwrtSha256} $f\" | sha256sum -c - >/dev/null && {env} MOSHIRO_CHANNEL='{plan.NativeChannel}' sh \"$f\" install" : ""); } @@ -136,7 +130,7 @@ public sealed class JoinTokenService lines.Add($"MOSHIRO_CONTROLLERS={string.Join(',', plan.Controllers)}"); } - lines.Add($"MOSHIRO_IMAGE={ImageRepository(role)}"); + lines.Add($"MOSHIRO_IMAGE={ImageRepository()}"); if (desktop) { // Docker Desktop 没有宿主网络 @@ -149,14 +143,8 @@ public sealed class JoinTokenService string linux = $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(false)}\nEOF\n" + $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | sha256sum -c - && podman compose up -d"; - string macos = role == ProbeRole - ? $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(true)}\nEOF\n" - + $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | shasum -a 256 -c - && docker compose up -d" - : ""; - string windows = role == ProbeRole - ? $"New-Item -ItemType Directory -Force {dir} | Out-Null; Set-Location {dir}\n@'\n{Env(true)}\n'@ | Set-Content -Encoding ascii .env\n" - + $"irm '{url}' -OutFile compose.yaml; if ((Get-FileHash compose.yaml -Algorithm SHA256).Hash -ne '{plan.ComposeSha256}') {{ throw 'compose.yaml does not match its published hash' }}\ndocker compose up -d" - : ""; + string macos = ""; + string windows = ""; return new ComposeInstall(Env(false), url, plan.ComposeSha256, linux, macos, windows); } @@ -184,36 +172,34 @@ public sealed class JoinTokenService string unit = $"$HOME/.config/containers/systemd/moshiro-{role}.container"; string command = $"mkdir -p ~/.config/moshiro ~/.config/containers/systemd/moshiro-{role}.container.d && cat > ~/.config/moshiro/{role}.env <<'EOF'\n{env}\nEOF\n" + $"chmod 600 ~/.config/moshiro/{role}.env && curl -fsSL '{url}' -o \"{unit}\" && echo \"{plan.QuadletSha256} {unit}\" | sha256sum -c - " - + $"&& printf '[Container]\\nImage={ImageRepository(role)}:latest\\n' > ~/.config/containers/systemd/moshiro-{role}.container.d/10-image.conf " + + $"&& printf '[Container]\\nImage={ImageRepository()}:latest\\n' > ~/.config/containers/systemd/moshiro-{role}.container.d/10-image.conf " + $"&& systemctl --user daemon-reload && systemctl --user start moshiro-{role}"; return new QuadletInstall(env, url, plan.QuadletSha256, command); } - /// 这个角色的镜像仓库地址(不带 tag):节点镜像仓库加上命名空间。 - private string ImageRepository(string role) + /// 节点镜像的仓库地址(不带 tag):节点镜像仓库加上命名空间。 + private string ImageRepository() { string registry = (_options.NodeImageRegistry ?? "").Trim('/'); string ns = _options.ImageNamespace.Trim('/'); - return $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}"; + return $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-agent"; } private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); /// - /// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。 - /// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。 + /// 这个一次性码现在还能用吗:安装脚本据此向面板取已签名的发布清单。 + /// 代码不存在、已吊销、已过期或用完了返回 false(不消耗它)。 /// - public async ValueTask RoleOfCodeAsync(string code, CancellationToken ct) + public async ValueTask IsUsableAsync(string code, CancellationToken ct) { if (code.Length is 0 or > 200) { - return null; + return false; } JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct); - return token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses - ? null - : RoleOf(token.Kind); + return token is not null && token.RevokedAtMs is null && token.ExpiresAtMs > _time.GetUtcNow().ToUnixTimeMilliseconds() && token.Uses < token.MaxUses; } public ValueTask> ListAsync(CancellationToken ct) => _store.ListAsync(ct); diff --git a/backend/src/MoShiro.Backend.Application/Admin/SettingsService.cs b/backend/src/MoShiro.Backend.Application/Admin/SettingsService.cs index 62102ee..65850cf 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/SettingsService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/SettingsService.cs @@ -46,9 +46,9 @@ public sealed class SettingsService private static IReadOnlyList Known(BackendOptions o) => [ new(SitePublic, "bool", "false", 0, 1, "Whether anonymous visitors can read the public API."), - new(SiteShowBuild, "bool", "false", 0, 1, "Whether the public pages show the versions, tags and build times of the backend, agents and probes."), - new(AutoLink, "bool", "true", 0, 1, "Whether every probe connects to every agent automatically; when off, only the links chosen by hand are connected."), - new(RestartEnabled, "bool", "true", 0, 1, "Whether agents and probes restart themselves once a day (they exit in order and the service manager starts them again); a local restart_at on the node wins."), + new(SiteShowBuild, "bool", "false", 0, 1, "Whether the public pages show the versions, tags and build times of the backend and the agents."), + new(AutoLink, "bool", "true", 0, 1, "Whether every node that does not listen connects to every listening node automatically; when off, only the links chosen by hand are connected. Links between listening nodes are always manual."), + new(RestartEnabled, "bool", "true", 0, 1, "Whether agents restart themselves once a day (they exit in order and the service manager starts them again); a local restart_at on the node wins."), new(RestartHour, "int", "4", 0, 23, "Hour of the daily restart, in the time zone below."), new(RestartMinute, "int", "30", 0, 59, "Minute of the daily restart."), new(RestartOffsetMinutes, "int", "480", -840, 840, "Time zone of the daily restart as minutes east of UTC (480 = Beijing)."), diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentEventService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentEventService.cs index aec1ee9..3ec677c 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentEventService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentEventService.cs @@ -3,7 +3,7 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; -/// moshiro.event:Agent 报告的事件(Probe 连上/断开、端点变化、时钟跳变、缺口、CPU 饱和)。 +/// moshiro.event:节点报告的事件(拨号方连上/断开、端点变化、时钟跳变、缺口、CPU 饱和)。 public sealed class AgentEventService { private readonly AgentRuntime _runtime; @@ -33,12 +33,12 @@ public sealed class AgentEventService AgentRuntimeState state = _runtime.Get(agent); switch (e.Kind) { - case AgentEventKinds.ProbeConnected when !string.IsNullOrEmpty(e.Detail): - state.AddProbe(e.Probe is { } p ? new ConnectedProbeInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at, p.IpVersion) : new ConnectedProbeInfo(e.Detail, null, null, at)); - _nodes.ObserveProbeInBackground(e.Detail, e.Probe?.Remote); + case AgentEventKinds.DialerConnected when !string.IsNullOrEmpty(e.Detail): + state.AddDialer(e.Dialer is { } p ? new ConnectedDialerInfo(e.Detail, p.Version, p.Remote, p.SinceUtcMs > 0 ? p.SinceUtcMs : at, p.IpVersion) : new ConnectedDialerInfo(e.Detail, null, null, at)); + _nodes.ObserveDialerInBackground(e.Detail, e.Dialer?.Remote); break; - case AgentEventKinds.ProbeDisconnected when !string.IsNullOrEmpty(e.Detail): - state.RemoveProbe(e.Detail, e.Probe?.IpVersion ?? IpVersions.V4); + case AgentEventKinds.DialerDisconnected when !string.IsNullOrEmpty(e.Detail): + state.RemoveDialer(e.Detail, e.Dialer?.IpVersion ?? IpVersions.V4); break; case AgentEventKinds.EndpointChanged: state.CountEndpointChange(); diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentRuntime.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentRuntime.cs index d2f6ffe..09aa90b 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentRuntime.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentRuntime.cs @@ -4,58 +4,58 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; -/// Agent 眼中的一个已连接 Probe(Agent 在事件和心跳里上报)。 -public sealed record ConnectedProbeInfo(string Id, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4); +/// 监听方眼中的一个已连接拨号方(监听方在事件和心跳里上报)。 +public sealed record ConnectedDialerInfo(string Id, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4); -/// Agent 的运行时状态(只在内存中):连着哪些 Probe、最近的进程内计数、端点变化次数。 +/// 节点的运行时状态(只在内存中):连着它的拨号方、最近的心跳、进程内计数、端点变化次数。 public sealed class AgentRuntimeState { private readonly object _gate = new(); private int _endpointChanges; - private IReadOnlyList _probes = []; + private IReadOnlyList _dialers = []; private string? _lastDisconnectReason; - /// 当前连着这个 Agent 的 Probe(每个 Probe 一项)。 - public IReadOnlyList Probes => Volatile.Read(ref _probes); + /// 当前连着这个节点的拨号方(每个拨号方每个 IP 族一项)。 + public IReadOnlyList Dialers => Volatile.Read(ref _dialers); - public bool ProbeConnected => Probes.Count > 0; + public bool DialerConnected => Dialers.Count > 0; - public bool IsConnected(string probeId, string ipVersion = IpVersions.V4) => Probes.Any(p => p.Id == probeId && p.IpVersion == ipVersion); + public bool IsConnected(string dialerId, string ipVersion = IpVersions.V4) => Dialers.Any(p => p.Id == dialerId && p.IpVersion == ipVersion); - /// 事件 probe_connected:事件里带着 Probe 的版本和地址;心跳里的完整快照随后会再校正一次。 - public void AddProbe(ConnectedProbeInfo info) + /// 事件 dialer_connected:事件里带着拨号方的版本和地址;心跳里的完整快照随后会再校正一次。 + public void AddDialer(ConnectedDialerInfo info) { lock (_gate) { - Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != info.Id || p.IpVersion != info.IpVersion), info]); + Volatile.Write(ref _dialers, [.. _dialers.Where(p => p.Id != info.Id || p.IpVersion != info.IpVersion), info]); } } - public void RemoveProbe(string id, string ipVersion = IpVersions.V4) + public void RemoveDialer(string id, string ipVersion = IpVersions.V4) { lock (_gate) { - Volatile.Write(ref _probes, [.. _probes.Where(p => p.Id != id || p.IpVersion != ipVersion)]); + Volatile.Write(ref _dialers, [.. _dialers.Where(p => p.Id != id || p.IpVersion != ipVersion)]); } } /// 心跳里的完整快照:覆盖事件推算出的结果。 - public void ReplaceProbes(IEnumerable probes) + public void ReplaceDialers(IEnumerable dialers) { lock (_gate) { - Volatile.Write(ref _probes, [.. probes.OrderBy(p => p.SinceMs).ThenBy(p => p.Id, StringComparer.Ordinal)]); + Volatile.Write(ref _dialers, [.. dialers.OrderBy(p => p.SinceMs).ThenBy(p => p.Id, StringComparer.Ordinal)]); } } - public void ClearProbes() => ReplaceProbes([]); + public void ClearDialers() => ReplaceDialers([]); /// 最新的主机资源占用快照,以及收到它的时间。 public HostSnapshot? Host { get; set; } public long HostReceivedAtMs { get; set; } - public AgentHeartbeatExtra? LastHeartbeat { get; set; } + public NodeHeartbeatExtra? LastHeartbeat { get; set; } public long LastHeartbeatAtMs { get; set; } @@ -85,17 +85,17 @@ public sealed class AgentRuntime public void Remove(NodeId agent) => _states.TryRemove(agent, out _); - /// 某个 Probe 现在连着哪些 Agent。 - public IReadOnlyList<(NodeId Agent, ConnectedProbeInfo Probe)> ConnectionsOf(string probeId) + /// 某个拨号方现在连着哪些监听节点。 + public IReadOnlyList<(NodeId Listener, ConnectedDialerInfo Dialer)> ConnectionsOf(string dialerId) { - var result = new List<(NodeId, ConnectedProbeInfo)>(); - foreach ((NodeId agent, AgentRuntimeState state) in _states) + var result = new List<(NodeId, ConnectedDialerInfo)>(); + foreach ((NodeId listener, AgentRuntimeState state) in _states) { - foreach (ConnectedProbeInfo probe in state.Probes) + foreach (ConnectedDialerInfo dialer in state.Dialers) { - if (probe.Id == probeId) + if (dialer.Id == dialerId) { - result.Add((agent, probe)); + result.Add((listener, dialer)); } } } @@ -103,12 +103,12 @@ public sealed class AgentRuntime return result; } - /// Agent 断开时,它对 Probe 会话的认知也随之失效。 + /// 节点断开时,它对拨号方会话的认知也随之失效。 public void MarkOffline(NodeId agent) { if (_states.TryGetValue(agent, out AgentRuntimeState? state)) { - state.ClearProbes(); + state.ClearDialers(); } } } diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index f952761..9da2872 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -7,10 +7,15 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; -public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList Probes, AgentHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null, string? LastDisconnectReason = null) +/// 本节点拨出的一条连接:监听方现在认的这个拨号方会话(由监听方上报)。 +public sealed record OutgoingConnection(NodeId ListenerId, string ListenerName, bool ListenerOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4); + +/// 现在连着这个节点的拨号方(监听方上报)。 +/// 这个节点现在连着的监听节点(监听方上报)。 +public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyList Dialers, IReadOnlyList Outgoing, NodeHeartbeatExtra? Heartbeat, int EndpointChanges, long? HeartbeatAtMs = null, HostSnapshot? Host = null, string? LastDisconnectReason = null) { - /// 至少有一个 Probe 连着。 - public bool ProbeConnected => Probes.Count > 0; + /// 至少有一个拨号方连着。 + public bool DialerConnected => Dialers.Count > 0; public NodeRuntimeStatus RuntimeStatus { @@ -49,7 +54,7 @@ public sealed record AgentView(AgentRecord Agent, bool Online, IReadOnlyListAgent 的管理:列表、修改、审批、启停、接管、删除,以及 Probe 需要的 Agent 列表。 +/// 节点的管理:列表、修改、审批、启停、接管、删除,以及每个节点要拨出的目标列表。 public sealed partial class AgentService { private readonly IAgentStore _agents; @@ -80,20 +85,40 @@ public sealed partial class AgentService public async ValueTask> ListAsync(CancellationToken ct) { IReadOnlyList agents = await _agents.ListAsync(ct); - return [.. agents.Select(View)]; + return [.. agents.Select(a => View(a, agents))]; } public async ValueTask GetAsync(NodeId id, CancellationToken ct) => - await _agents.GetAsync(id, ct) is { } agent ? View(agent) : null; + await _agents.GetAsync(id, ct) is { } agent ? View(agent, await _agents.ListAsync(ct)) : null; - public AgentView View(AgentRecord agent) + public AgentView View(AgentRecord agent, IReadOnlyList all) { _runtime.TryGet(agent.Id, out AgentRuntimeState? state); - return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Probes ?? [], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host, state?.LastDisconnectReason); + var outgoing = new List(); + foreach ((NodeId listenerId, ConnectedDialerInfo info) in _runtime.ConnectionsOf(agent.Id.Value)) + { + if (all.FirstOrDefault(a => a.Id == listenerId) is { } listener) + { + outgoing.Add(new OutgoingConnection(listenerId, listener.Name, _gateway.IsOnline(listenerId), info.Version, info.Remote, info.SinceMs, info.IpVersion)); + } + } + + return new AgentView(agent, _gateway.IsOnline(agent.Id), state?.Dialers ?? [], [.. outgoing.OrderBy(c => c.ListenerName, StringComparer.Ordinal)], state?.LastHeartbeat, state?.EndpointChanges ?? 0, state?.LastHeartbeatAtMs, state?.Host, state?.LastDisconnectReason); } - public ValueTask NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) => - _gateway.NotifyProbeLinkRetryAsync(probe, agent, ct); + /// 让监听方断开某个拨号方的会话(拨号方按退避重连,除非它被禁用)。 + public async ValueTask> DisconnectDialerAsync(NodeId listener, NodeId dialer, CancellationToken ct) + { + if (await _agents.GetAsync(dialer, ct) is null || await _agents.GetAsync(listener, ct) is null) + { + return Result.Fail(MoShiroErrors.AgentNotFound, "node not found"); + } + + return await _gateway.DisconnectDialerAsync(listener, dialer, ct); + } + + public ValueTask NotifyLinkRetryAsync(NodeId dialer, NodeId listener, CancellationToken ct) => + _gateway.NotifyLinkRetryAsync(dialer, listener, ct); private static bool ValidHost(string host, string ipVersion) { @@ -152,7 +177,7 @@ public sealed partial class AgentService await AfterAgentSetChangedAsync(ct); } - return Result.Ok(View(agent)); + return Result.Ok(View(agent, await _agents.ListAsync(ct))); } public ValueTask> ApproveAsync(NodeId id, CancellationToken ct) => ChangeStatusAsync(id, NodeStatus.Active, ct); @@ -181,7 +206,7 @@ public sealed partial class AgentService // 公钥换了,旧连接使用的是旧密钥 await _gateway.DisconnectAsync(agent.Id, "key replaced", ct); await AfterAgentSetChangedAsync(ct); - return Result.Ok(View(agent)); + return Result.Ok(View(agent, await _agents.ListAsync(ct))); } public async ValueTask DeleteAsync(NodeId id, CancellationToken ct) @@ -195,26 +220,24 @@ public sealed partial class AgentService await AfterAgentSetChangedAsync(ct); } - /// 这个 Probe 要连接的 Agent 列表(已激活、有公开地址,并且线路是开着的)。版本号在列表变化时递增。 - public async ValueTask ProbeAgentsAsync(string probeId, CancellationToken ct) + /// 这个节点要拨出的监听节点列表(激活、可拨号,并且线路是开着的)。版本号在列表变化时递增。 + public async ValueTask DialTargetsAsync(AgentRecord dialer, CancellationToken ct) { LinkSnapshot links = await _links.SnapshotAsync(ct); IReadOnlyList agents = await _agents.ListAsync(ct); - long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0; - var result = new ProbeAgentList { Version = version }; - // 没有上报证书公钥的 Agent(旧版本)还不能被 Probe 连接:Probe 需要它来固定证书和派生敲门密钥 - foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && a.TlsPublicKey is { Length: > 0 } && links.IsLinked(probeId, a.Id.Value))) + long version = long.TryParse(await _settings.GetAsync(TaskPlanner.DialTargetsVersionKey, ct), out long v) ? v : 0; + var result = new DialTargets { Version = version }; + foreach (AgentRecord listener in agents.Where(a => a.Dialable && links.IsLinked(dialer, a))) { - result.Agents.Add(new AgentEndpoint + result.Targets.Add(new DialTarget { - AgentId = agent.Id, - Name = agent.Name, - Host = agent.PublicHost, - Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null, - TcpPort = agent.ProbeTcpPort, - QuicPorts = [.. agent.ProbeUdpPorts], - LegacyUdpPorts = [.. agent.ProbeUdpPorts], - PublicKey = Convert.ToBase64String(agent.TlsPublicKey!), + NodeId = listener.Id, + Name = listener.Name, + Host = listener.PublicHost, + Ipv6Host = listener.Ipv6Supported && listener.Ipv6Host.Length > 0 ? listener.Ipv6Host : null, + TcpPort = listener.TcpPort, + QuicPorts = [.. listener.QuicPorts], + PublicKey = Convert.ToBase64String(listener.TlsPublicKey!), }); } @@ -224,10 +247,10 @@ public sealed partial class AgentService return result; } - /// Agent 集合变了:递增列表版本,重算各 Agent 的期望状态,并把新的 Agent 列表推给 Probe(都是期望状态,一起通知)。 + /// 节点集合或线路变了:递增拨号目标版本,重算各节点的期望状态(任务集和拨号目标都是期望状态,一起通知)。 public async ValueTask AfterAgentSetChangedAsync(CancellationToken ct) { - await _planner.BumpProbeAgentsVersionAsync(ct); + await _planner.BumpDialTargetsVersionAsync(ct); _gateway.NotifyAllTaskSetsChanged(); } @@ -241,7 +264,7 @@ public sealed partial class AgentService await AfterAgentSetChangedAsync(ct); _gateway.NotifyTaskSetChanged(id); - return Result.Ok(View(agent)); + return Result.Ok(View(agent, await _agents.ListAsync(ct))); } [GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")] diff --git a/backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs b/backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs index 0103de4..b582027 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/HostStatusService.cs @@ -4,7 +4,7 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// -/// moshiro.host:Agent 和 Probe 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。 +/// moshiro.host:Agent 每隔几秒上报的主机资源占用快照。最新值放在内存里并实时推给订阅者;每个节点每分钟存一个样本做历史。 /// 快照里没有 CPU 型号、主机名、网卡名和 IP(节点根本不采集它们)。 /// public sealed class HostStatusService diff --git a/backend/src/MoShiro.Backend.Application/Agents/LinkRuntime.cs b/backend/src/MoShiro.Backend.Application/Agents/LinkRuntime.cs new file mode 100644 index 0000000..d97e824 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Agents/LinkRuntime.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +public sealed record ReportedLink(LinkHealth Health, long AtMs); + +/// Latest per-line control and task health reported by dialing nodes. Volatile runtime state, not historical metrics. +public sealed class LinkRuntime +{ + private readonly ConcurrentDictionary<(string Dialer, string Listener, string IpVersion), ReportedLink> _links = new(); + + public ReportedLink? Get(string dialerId, string listenerId, string ipVersion) => + _links.TryGetValue((dialerId, listenerId, ipVersion), out ReportedLink? snapshot) ? snapshot : null; + + public void Replace(string dialerId, long atMs, IReadOnlyList links) + { + var current = new HashSet<(string Dialer, string Listener, string IpVersion)>(); + foreach (LinkHealth link in links) + { + var key = (dialerId, link.ListenerId, link.IpVersion); + current.Add(key); + _links[key] = new ReportedLink(link, atMs); + } + + foreach ((string Dialer, string Listener, string IpVersion) key in _links.Keys.Where(k => k.Dialer == dialerId && !current.Contains(k))) + { + _links.TryRemove(key, out _); + } + } + + /// 拨号方与 Backend 的连接中断:它到各监听方的线路状态不再可知,按控制层退避显示,保留最后一次的统计。 + public void MarkDialerOffline(string dialerId, long atMs) + { + foreach (KeyValuePair<(string Dialer, string Listener, string IpVersion), ReportedLink> pair in _links.ToArray().Where(p => p.Key.Dialer == dialerId)) + { + LinkHealth old = pair.Value.Health; + old.Control.State = ControlState.Backoff; + old.Control.SinceMs = atMs; + old.Control.LastError = new LinkFault { Phase = "backend", Kind = "node_offline", Message = "节点与 Backend 的连接中断" }; + old.Control.RetryInMs = null; + old.Task.Quic.State = QuicState.Idle; + old.Task.Quic.RetryInMs = null; + _links[pair.Key] = new ReportedLink(old, atMs); + } + } +} diff --git a/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs b/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs index 0ee14ea..d973dd5 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/LinkService.cs @@ -1,30 +1,31 @@ namespace MoShiro.Backend.Application; -/// 某一时刻的「线路」开关:自动连接的总开关,加上每一对(Probe,Agent)的手动设置。 +/// 某一时刻的「线路」开关:自动连接的总开关,加上每一对(拨号方,监听方)的手动设置。 public sealed class LinkSnapshot { - private readonly Dictionary<(string Probe, string Agent), bool> _overrides; + private readonly Dictionary<(string Dialer, string Listener), bool> _overrides; - public LinkSnapshot(bool auto, Dictionary<(string Probe, string Agent), bool> overrides) + public LinkSnapshot(bool auto, Dictionary<(string Dialer, string Listener), bool> overrides) { Auto = auto; _overrides = overrides; } - /// 自动连接:开着时,没有手动设置的 Probe 和 Agent 都会连;关着时,只有手动开启的才连。 + /// 自动连接:开着时,所有节点自动拨其他监听节点;关着时,只有手动开启的才连。两个监听节点默认双向互连。 public bool Auto { get; } /// 手动设置:true 强制连、false 强制不连、null 没设置(跟随自动连接)。 - public bool? OverrideOf(string probeId, string agentId) => _overrides.TryGetValue((probeId, agentId), out bool value) ? value : null; + public bool? OverrideOf(string dialerId, string listenerId) => _overrides.TryGetValue((dialerId, listenerId), out bool value) ? value : null; /// 这一对最终连不连:手动设置优先,没有就看自动连接。 - public bool IsLinked(string probeId, string agentId) => OverrideOf(probeId, agentId) ?? Auto; + public bool IsLinked(AgentRecord dialer, AgentRecord listener) => + dialer.Id != listener.Id && (OverrideOf(dialer.Id.Value, listener.Id.Value) ?? Auto); } /// -/// 哪些(Probe,Agent)要连成线路。由面板决定、Probe 主动拨出:自动连接开着时所有 Probe 和 Agent 两两连(默认), -/// 关着时只连手动选中的;每一对还可以手动强制开或关。手动设置存在设置表里(link.<probe>.<agent>,值 1 或 0,空表示没设置)。 -/// 判断结果分别用在 Probe 的 Agent 列表、Agent 允许的 Probe(peers)和线路目录里;改动之后要调用 重新下发。 +/// 哪些(拨号方,监听方)要连成线路。由面板决定、拨号方主动拨出:自动连接开着时,所有节点拨其他监听节点(默认), +/// 关着时只连手动选中的;每一对还可以手动强制开或关,每个方向的线路独立设置。手动设置存在设置表里(link.<拨号方>.<监听方>,值 1 或 0,空表示没设置)。 +/// 判断结果分别用在拨号方的拨号目标、监听方允许的拨号方(peers)和线路目录里;改动之后要调用 重新下发。 /// public sealed class LinkService { @@ -54,10 +55,10 @@ public sealed class LinkService } /// 手动设置一对线路:true 强制连、false 强制不连、null 取消手动设置。 - public ValueTask SetAsync(string probeId, string agentId, bool? enabled, CancellationToken ct) => - _settings.SetAsync($"{Prefix}{probeId}.{agentId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct); + public ValueTask SetAsync(string dialerId, string listenerId, bool? enabled, CancellationToken ct) => + _settings.SetAsync($"{Prefix}{dialerId}.{listenerId}", enabled is null ? "" : enabled.Value ? "1" : "0", ct); - /// Probe 或 Agent 被删除:清掉所有涉及它的手动设置。 + /// 节点被删除:清掉所有涉及它的手动设置。 public async ValueTask ForgetNodeAsync(string nodeId, CancellationToken ct) { foreach (string key in (await _settings.GetAllAsync(ct)).Keys.Where(k => k.StartsWith(Prefix, StringComparison.Ordinal) && k.Split('.').Skip(1).Contains(nodeId))) diff --git a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs index f3dbfdc..9499dc2 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs @@ -6,7 +6,7 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; -/// MoShiro 在 Fleet 之上的接入点:注册之后补全公开地址与端口。 +/// MoShiro 在 Fleet 之上的接入点:注册之后补全公开地址与端口(不监听的节点没有)。 public sealed class MoShiroEnrollmentHook : IEnrollmentHook { private readonly IAgentStore _agents; @@ -20,19 +20,12 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct) { - if (node.Id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal)) - { - // Probe:新的对等面公钥要进每个 Agent 的名单,Probe 自己也要拿到 Agent 列表 - await _service.AfterAgentSetChangedAsync(ct); - return; - } - - AgentEnrollMeta meta = new(); + NodeEnrollMeta meta = new(); if (context.ProductMeta is { } raw) { try { - meta = raw.Deserialize(MoShiroJsonContext.Default.AgentEnrollMeta) ?? meta; + meta = raw.Deserialize(MoShiroJsonContext.Default.NodeEnrollMeta) ?? meta; } catch (JsonException) { @@ -41,10 +34,11 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook } // 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址) - string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost + string host = meta.TcpPort <= 0 ? "" + : !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost : System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : ""; - await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeQuicPorts.Count > 0 ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts ?? [7443], meta.Kinds), ct); - if (await _agents.GetAsync(node.Id, ct) is { } enrolled) + await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.TcpPort, meta.QuicPorts, meta.Kinds), ct); + if (meta.TcpPort > 0 && await _agents.GetAsync(node.Id, ct) is { } enrolled) { (_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp); await _agents.ApplyAutoAddressesAsync(node.Id, null, observedV6, ct); @@ -57,7 +51,7 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook } } -/// 节点上线、下线、hello、心跳:更新运行时状态,补全 Agent 信息,并推送给 SSE。 +/// 节点上线、下线、hello、心跳:更新运行时状态,补全节点信息,并推送给 SSE。 public sealed class MoShiroNodeEventSink : INodeEventSink { private readonly IAgentStore _agents; @@ -68,21 +62,17 @@ public sealed class MoShiroNodeEventSink : INodeEventSink private readonly NodeInfoService _nodes; private readonly ReleaseService _releases; private readonly ScheduleService _schedule; - private readonly IProbeStore _probes; private readonly NodeOwnerService _owners; - private readonly ProbeQuicRuntime _quicRuntime; - private readonly ProbeRuntime _probeRuntime; + private readonly LinkRuntime _linkRuntime; private readonly NodeRuntimeLogService _nodeLogs; - public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners, ProbeQuicRuntime quicRuntime, ProbeRuntime probeRuntime, NodeRuntimeLogService nodeLogs) + public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, NodeOwnerService owners, LinkRuntime linkRuntime, NodeRuntimeLogService nodeLogs) { _nodeLogs = nodeLogs; _owners = owners; - _quicRuntime = quicRuntime; - _probeRuntime = probeRuntime; + _linkRuntime = linkRuntime; _releases = releases; _schedule = schedule; - _probes = probes; _nodes = nodes; _agents = agents; _runtime = runtime; @@ -94,12 +84,6 @@ public sealed class MoShiroNodeEventSink : INodeEventSink public async ValueTask OnEventAsync(NodeEvent nodeEvent, CancellationToken ct) { NodeId id = nodeEvent.Node.Id; - if (id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal)) - { - await OnProbeEventAsync(id, nodeEvent, ct); - return; - } - switch (nodeEvent.Kind) { case NodeEventKind.Online: @@ -111,6 +95,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink _runtime.Get(id).MarkOffline(nodeEvent.Reason); _runtime.MarkOffline(id); _releases.RecordStatus(id, null); + _linkRuntime.MarkDialerOffline(id.Value, nodeEvent.AtMs); _live.Publish(new NodeSignal("agent", id.Value, false, nodeEvent.AtMs)); await _events.AddAsync(id, "agent_offline", nodeEvent.AtMs, null, ct); break; @@ -118,112 +103,60 @@ public sealed class MoShiroNodeEventSink : INodeEventSink await ApplyHelloAsync(id, hello, nodeEvent.RemoteIp, ct); break; case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra: - await _nodes.SetVersionAsync(id.Value, NodeKinds.Agent, nodeEvent.Node.NodeVersion, ct); - if (_nodes.ShouldRetryGeo(id.Value) && await _agents.GetAsync(id, ct) is { } unlocated) - { - _nodes.ObserveAgentInBackground(id.Value, GeoHostOf(unlocated, nodeEvent.RemoteIp), null); - } - - try - { - AgentRuntimeState state = _runtime.Get(id); - AgentHeartbeatExtra? beat = extra.Deserialize(MoShiroJsonContext.Default.AgentHeartbeatExtra); - state.LastHeartbeat = beat; - state.LastHeartbeatAtMs = nodeEvent.AtMs; - _releases.RecordStatus(id, beat?.Update); - if (beat is not null) - { - _nodeLogs.Append(id, beat.RuntimeLogs, beat.DroppedRuntimeLogs ?? 0); - state.ReplaceProbes(beat.Probes.Select(p => new ConnectedProbeInfo(p.Id.Value, p.Version, p.Remote, p.SinceUtcMs, p.IpVersion))); - foreach (ConnectedProbe p in beat.Probes) - { - _nodes.ObserveProbeInBackground(p.Id.Value, p.Remote); - } - } - } - catch (JsonException) - { - // 忽略格式不正确的附加字段 - } - + await ApplyHeartbeatAsync(id, nodeEvent, extra, ct); break; } } + private async ValueTask ApplyHeartbeatAsync(NodeId id, NodeEvent nodeEvent, System.Text.Json.JsonElement extra, CancellationToken ct) + { + await _nodes.SetVersionAsync(id.Value, nodeEvent.Node.NodeVersion, ct); + if (_nodes.ShouldRetryGeo(id.Value) && await _agents.GetAsync(id, ct) is { } unlocated) + { + _nodes.ObserveAgentInBackground(id.Value, GeoHostOf(unlocated, nodeEvent.RemoteIp), null); + } + + try + { + AgentRuntimeState state = _runtime.Get(id); + NodeHeartbeatExtra? beat = extra.Deserialize(MoShiroJsonContext.Default.NodeHeartbeatExtra); + state.LastHeartbeat = beat; + state.LastHeartbeatAtMs = nodeEvent.AtMs; + _releases.RecordStatus(id, beat?.Update); + if (beat is null) + { + return; + } + + _nodeLogs.Append(id, beat.RuntimeLogs, beat.DroppedRuntimeLogs ?? 0); + state.ReplaceDialers(beat.Dialers.Select(d => new ConnectedDialerInfo(d.Id.Value, d.Version, d.Remote, d.SinceUtcMs, d.IpVersion))); + foreach (ConnectedDialer dialer in beat.Dialers) + { + _nodes.ObserveDialerInBackground(dialer.Id.Value, dialer.Remote); + } + + if (beat.Links is { } links) + { + _linkRuntime.Replace(id.Value, nodeEvent.AtMs, links); + } + + if (beat.Ipv6 is { } ipv6) + { + await _agents.SetIpv6DiagnosticsAsync(id, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct); + } + } + catch (JsonException) + { + // 忽略格式不正确的附加字段 + } + } + /// 查国家和运营商用哪个地址:对外地址优先,其次 IPv6 地址,都没有(NAT 后面、或者没有上报公网地址)才用这条连接的来源地址。 private static string? GeoHostOf(AgentRecord? agent, string? remoteIp) => !string.IsNullOrEmpty(agent?.PublicHost) ? agent.PublicHost : !string.IsNullOrEmpty(agent?.Ipv6Host) ? agent.Ipv6Host : remoteIp; - /// Probe 自己连上来了:hello 里带着它的系统、架构和构建信息;上线下线只推给 SSE,不记事件。 - private async ValueTask OnProbeEventAsync(NodeId id, NodeEvent nodeEvent, CancellationToken ct) - { - switch (nodeEvent.Kind) - { - case NodeEventKind.Online: - _probeRuntime.MarkOnline(id.Value); - _live.Publish(new NodeSignal("probe", id.Value, true, nodeEvent.AtMs)); - break; - case NodeEventKind.Hello: - if (nodeEvent.Hello?.Meta is { } raw) - { - try - { - ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta); - await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct); - _owners.Report(id.Value, meta?.Owners); - if (meta?.Ipv6 is { } ipv6) - { - await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct); - } - } - catch (JsonException) - { - // 格式不对就忽略 - } - } - - await _nodes.SetVersionAsync(id.Value, NodeKinds.Probe, nodeEvent.Node.NodeVersion, ct); - - await _releases.OfferToNodeAsync(id, ct); - await _schedule.SendToNodeAsync(id, ct); - break; - case NodeEventKind.Offline: - _live.Publish(new NodeSignal("probe", id.Value, false, nodeEvent.AtMs)); - _releases.RecordStatus(id, null); - _probeRuntime.MarkOffline(id.Value, nodeEvent.Reason); - _quicRuntime.MarkProbeOffline(id.Value, nodeEvent.AtMs); - break; - case NodeEventKind.Heartbeat when nodeEvent.Extra is { } extra: - await _nodes.SetVersionAsync(id.Value, NodeKinds.Probe, nodeEvent.Node.NodeVersion, ct); - try - { - ProbeHeartbeatExtra? heartbeat = extra.Deserialize(MoShiroJsonContext.Default.ProbeHeartbeatExtra); - _releases.RecordStatus(id, heartbeat?.Update); - if (heartbeat is not null) - { - _nodeLogs.Append(id, heartbeat.RuntimeLogs, heartbeat.DroppedRuntimeLogs ?? 0); - _probeRuntime.RecordHeartbeat(id.Value, nodeEvent.AtMs, heartbeat); - } - if (heartbeat?.QuicLinks is { } quicLinks) - { - _quicRuntime.Replace(id.Value, nodeEvent.AtMs, quicLinks); - } - if (heartbeat?.Ipv6 is { } ipv6) - { - await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct); - } - } - catch (JsonException) - { - // 忽略格式不正确的附加字段 - } - - break; - } - } - private async ValueTask ApplyHelloAsync(NodeId id, HelloParams hello, string? remoteIp, CancellationToken ct) { var kinds = new List(); @@ -235,12 +168,12 @@ public sealed class MoShiroNodeEventSink : INodeEventSink } } - AgentHelloMeta meta = new(); + NodeHelloMeta meta = new(); if (hello.Meta is { } raw) { try { - meta = raw.Deserialize(MoShiroJsonContext.Default.AgentHelloMeta) ?? meta; + meta = raw.Deserialize(MoShiroJsonContext.Default.NodeHelloMeta) ?? meta; } catch (JsonException) { @@ -248,53 +181,33 @@ public sealed class MoShiroNodeEventSink : INodeEventSink } } - // Agent 上报 TLS 证书的公钥:指纹由面板自己算,不信节点报的指纹 - byte[]? tlsKey = null; - byte[]? spki = null; - if (meta.TlsPublicKey is not null) - { - try - { - byte[] candidate = Convert.FromBase64String(meta.TlsPublicKey); - if (Fleet.Identity.NodeIdentity.IsValidPublicKey(candidate)) - { - tlsKey = candidate; - spki = Fleet.Identity.NodeIdentity.Fingerprint(candidate); - } - } - catch (FormatException) - { - // 忽略 - } - } - _owners.Report(id.Value, meta.Owners); if (meta.Platform is { } platform) { platform.Version = hello.NodeVersion; } - await _nodes.SetVersionAsync(id.Value, NodeKinds.Agent, hello.NodeVersion, ct); + + await _nodes.SetVersionAsync(id.Value, hello.NodeVersion, ct); AgentRecord? before = await _agents.GetAsync(id, ct); string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits); string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic); - IReadOnlyList? quicPorts = meta.ProbeQuicPorts is { Count: > 0 } ? meta.ProbeQuicPorts : meta.LegacyProbeUdpPorts; - await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, quicPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct); + await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, meta.Listen, ipv6Json), ct); - // 自动识别对外地址:Agent 上报的公网地址;手填的不动 - if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported) + // 自动识别对外地址:监听的节点上报的公网地址;手填的不动 + if (meta.Listen?.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported) { - (string? publicHost, string? ipv6Host) = AgentAddressPolicy.Choose(reported, meta.Addresses, null); + (string? publicHost, string? ipv6Host) = AgentAddressPolicy.Choose(reported, meta.Listen.Addresses, null); await _agents.ApplyAutoAddressesAsync(id, publicHost, ipv6Host, ct); } - // 端口、证书指纹或对外地址变了,Probe 需要新的列表 AgentRecord? after = await _agents.GetAsync(id, ct); _nodes.ObserveAgentInBackground(id.Value, GeoHostOf(after, remoteIp), meta.Platform); await _releases.OfferToNodeAsync(id, ct); await _schedule.SendToNodeAsync(id, ct); - if (before is not null && after is not null - && (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || !(before.TlsPublicKey ?? []).AsSpan().SequenceEqual(after.TlsPublicKey ?? []) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported || before.PublicHost != after.PublicHost || before.Ipv6Host != after.Ipv6Host) - && after.Status == NodeStatus.Active) + + // 监听状态、端口或对外地址变了,拨号方需要新的目标列表 + if (before is not null && after is not null && after.Status == NodeStatus.Active + && (before.Listens != after.Listens || before.TcpPort != after.TcpPort || !before.QuicPorts.SequenceEqual(after.QuicPorts) || before.Ipv6Supported != after.Ipv6Supported || before.PublicHost != after.PublicHost || before.Ipv6Host != after.Ipv6Host)) { await _service.AfterAgentSetChangedAsync(ct); } diff --git a/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs b/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs index a49d10c..2c1f431 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs @@ -6,7 +6,7 @@ namespace MoShiro.Backend.Application; public sealed record NodeOwner(string CredentialId, string? PasskeyName, string? Username); /// -/// 节点的属主:Agent 和 Probe 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。 +/// 节点的属主:Agent 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。 /// 属主的强制在节点本地(没有属主的节点不运行,见 OwnerGuard),面板这边只是顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上—— /// 以后给节点更多的接口和操作(extension 会话、加密内容)时,据此判断谁有资格用。上报的只是自述,真正的授权由节点自己对 passkey 签名的校验把关。 /// 在内存里:节点每次连上来都会重新报。 diff --git a/backend/src/MoShiro.Backend.Application/Agents/NodeRuntimeLogService.cs b/backend/src/MoShiro.Backend.Application/Agents/NodeRuntimeLogService.cs index 5cd32f9..15cc6f1 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/NodeRuntimeLogService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/NodeRuntimeLogService.cs @@ -8,7 +8,7 @@ public sealed record NodeRuntimeLog(long Id, long AtUtcMs, string Level, string public sealed record NodeRuntimeLogSnapshot(IReadOnlyList Items, long Dropped); -/// Bounded in-memory recent logs uploaded by authenticated Agent and Probe heartbeats. +/// Bounded in-memory recent logs uploaded by authenticated Agent and 拨号方 heartbeats. public sealed class NodeRuntimeLogService(TimeProvider time) { private const int CapacityPerNode = 256; diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeQuicRuntime.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeQuicRuntime.cs deleted file mode 100644 index 951c236..0000000 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeQuicRuntime.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Concurrent; -using MoShiro.Protocol; - -namespace MoShiro.Backend.Application; - -public sealed record ProbeQuicLinkSnapshot(ProbeQuicLinkHealth Health, long AtMs); - -/// Latest per-line QUIC health reported by connected Probes. Volatile runtime state, not historical metrics. -public sealed class ProbeQuicRuntime -{ - private readonly ConcurrentDictionary<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> _links = new(); - - public ProbeQuicLinkSnapshot? Get(string probeId, string agentId, string ipVersion) => - _links.TryGetValue((probeId, agentId, ipVersion), out ProbeQuicLinkSnapshot? snapshot) ? snapshot : null; - - public void Replace(string probeId, long atMs, IReadOnlyList links) - { - var current = new HashSet<(string Probe, string Agent, string IpVersion)>(); - foreach (ProbeQuicLinkHealth link in links) - { - var key = (probeId, link.AgentId, link.IpVersion); - current.Add(key); - _links[key] = new ProbeQuicLinkSnapshot(link, atMs); - } - - foreach ((string Probe, string Agent, string IpVersion) key in _links.Keys.Where(k => k.Probe == probeId && !current.Contains(k))) - { - _links.TryRemove(key, out _); - } - } - - public void MarkProbeOffline(string probeId, long atMs) - { - foreach (KeyValuePair<(string Probe, string Agent, string IpVersion), ProbeQuicLinkSnapshot> pair in _links.ToArray().Where(p => p.Key.Probe == probeId)) - { - ProbeQuicLinkHealth old = pair.Value.Health; - _links[pair.Key] = new ProbeQuicLinkSnapshot(new ProbeQuicLinkHealth - { - AgentId = old.AgentId, - IpVersion = old.IpVersion, - Connected = false, - State = ProbeLinkAttemptState.RetryWait, - LastError = "Probe 与 Backend 的连接中断", - RetryAttempt = old.RetryAttempt, - RetryInMs = old.RetryInMs, - HandshakeAttempts = old.HandshakeAttempts, - HandshakeSuccesses = old.HandshakeSuccesses, - HandshakeFailures = old.HandshakeFailures, - HandshakeTimeouts = old.HandshakeTimeouts, - HandshakeP95Ms = old.HandshakeP95Ms, - HandshakeSampleCount = old.HandshakeSampleCount, - TransportPacketLossRate = old.TransportPacketLossRate, - TransportPacketsSent = old.TransportPacketsSent, - TransportCongestionEvents = old.TransportCongestionEvents, - TransportEcnCongestionEvents = old.TransportEcnCongestionEvents, - PathMtu = old.PathMtu, - }, atMs); - } - } -} diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeRuntime.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeRuntime.cs deleted file mode 100644 index f26d966..0000000 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeRuntime.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Concurrent; -using MoShiro.Protocol; - -namespace MoShiro.Backend.Application; - -/// Volatile runtime status reported by each Probe's heartbeat and Fleet connection lifecycle. -public sealed class ProbeRuntime -{ - private readonly ConcurrentDictionary _probes = new(StringComparer.Ordinal); - - public ProbeRuntimeSnapshot? Get(string probeId) => _probes.TryGetValue(probeId, out ProbeRuntimeSnapshot? snapshot) ? snapshot : null; - - public void RecordHeartbeat(string probeId, long atMs, ProbeHeartbeatExtra heartbeat) => - _probes[probeId] = new ProbeRuntimeSnapshot(heartbeat, atMs, null); - - public void MarkOnline(string probeId) => _probes[probeId] = new ProbeRuntimeSnapshot(null, 0, null); - - public void MarkOffline(string probeId, string? reason) => - _probes.AddOrUpdate(probeId, _ => new ProbeRuntimeSnapshot(null, 0, reason), (_, current) => current with { DisconnectReason = reason }); -} - -public sealed record ProbeRuntimeSnapshot(ProbeHeartbeatExtra? Heartbeat, long AtMs, string? DisconnectReason); diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs deleted file mode 100644 index d2f4d30..0000000 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System.Text.RegularExpressions; -using Fleet.Core; -using Fleet.Server; -using MoShiro.Protocol; - -namespace MoShiro.Backend.Application; - -/// 某个 Probe 现在连着的一个 Agent(Agent 上报)。 -public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4); - -public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList Connections, ProbeRuntimeSnapshot? Runtime = null) -{ - public bool Revoked => Probe.RevokedAtMs is not null; - - public NodeRuntimeStatus RuntimeStatus - { - get - { - if (Revoked) - { - return NodeRuntimeStatus.Maintenance; - } - if (!Online) - { - if (Runtime?.DisconnectReason == "updating") - { - return NodeRuntimeStatus.Updating; - } - if (Runtime?.DisconnectReason == "stopping") - { - return Runtime.Heartbeat?.Update?.State == "applying" ? NodeRuntimeStatus.Updating : NodeRuntimeStatus.Stopping; - } - return NodeRuntimeStatus.Offline; - } - if (Runtime?.Heartbeat?.Update?.State == "applying") - { - return NodeRuntimeStatus.Updating; - } - if (Runtime?.Heartbeat is null) - { - return NodeRuntimeStatus.Initializing; - } - return Runtime.Heartbeat.RuntimeStatus; - } - } -} - -/// -/// Probe:用加入令牌注册(密钥由 Probe 自己生成,Backend 从不接触私钥),可以有多个,每个都能改名、吊销、删除。 -/// 所有未吊销的 Probe 的对等面公钥都会写进每个 Agent 的期望状态(peers);吊销、删除之后,Agent 立刻断开对应会话,Probe 到面板的连接也被断开。 -/// 要换密钥就吊销后重新注册(Probe 的 id 会变)。 -/// 一条线路是一对(Probe,Agent)。 -/// -public sealed partial class ProbeService -{ - private readonly IProbeStore _probes; - private readonly IAgentStore _agents; - private readonly AgentRuntime _runtime; - private readonly IAgentGateway _gateway; - private readonly TimeProvider _time; - private readonly NodeInfoService _nodes; - private readonly LinkService _links; - private readonly ProbeRuntime _probeRuntime; - - private readonly NodeOwnerService _owners; - - public ProbeService(NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links, ProbeRuntime probeRuntime) - { - _owners = owners; - _links = links; - _probeRuntime = probeRuntime; - _nodes = nodes; - _probes = probes; - _agents = agents; - _runtime = runtime; - _gateway = gateway; - _time = time; - } - - public async ValueTask> ListAsync(CancellationToken ct) - { - IReadOnlyList probes = await _probes.ListAsync(ct); - IReadOnlyList agents = await _agents.ListAsync(ct); - return [.. probes.Select(p => View(p, agents))]; - } - - public async ValueTask GetAsync(string id, CancellationToken ct) => - await _probes.GetAsync(id, ct) is { } probe ? View(probe, await _agents.ListAsync(ct)) : null; - - public async ValueTask> RenameAsync(string id, string name, CancellationToken ct) - { - if (await _probes.GetAsync(id, ct) is not { } probe) - { - return Result.Fail(MoShiroErrors.ProbeNotFound, "probe not found"); - } - - string trimmed = name.Trim(); - if (!NamePattern().IsMatch(trimmed)) - { - return ValidationError.Fail("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'"); - } - - if ((await _probes.ListAsync(ct)).Any(p => p.Id != id && string.Equals(p.Name, trimmed, StringComparison.OrdinalIgnoreCase))) - { - return Result.Fail(MoShiroErrors.NameConflict, $"a probe named {trimmed} already exists"); - } - - await _probes.SaveAsync(probe with { Name = trimmed }, ct); - return Result.Ok((await GetAsync(id, ct))!); - } - - /// 吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。 - public async ValueTask> RevokeAsync(string id, CancellationToken ct) - { - if (await _probes.GetAsync(id, ct) is null) - { - return Result.Fail(MoShiroErrors.ProbeNotFound, "probe not found"); - } - - await _probes.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); - // 吊销会阻止未来认证;也断开当前直连 Backend 的长会话,不等待 Probe 心跳超时。 - await _gateway.DisconnectAsync(new NodeId(id), "probe revoked", ct); - _gateway.NotifyAllTaskSetsChanged(); - return Result.Ok((await GetAsync(id, ct))!); - } - - /// 删除:Probe 身份和它的线路从面板消失;已经入库的数据按保留策略自然过期。 - public async ValueTask DeleteAsync(string id, CancellationToken ct) - { - bool deleted = await _probes.DeleteAsync(id, ct); - if (deleted) - { - await _nodes.ForgetAsync(id, ct); - _owners.Forget(id); - await _links.ForgetNodeAsync(id, ct); - _gateway.NotifyAllTaskSetsChanged(); - } - - return deleted; - } - - /// 让某个 Agent 断开这个 Probe(Probe 会按退避重连,除非它已经被吊销)。 - public async ValueTask> DisconnectAsync(string probeId, NodeId agentId, CancellationToken ct) - { - if (await _probes.GetAsync(probeId, ct) is null) - { - return Result.Fail(MoShiroErrors.ProbeNotFound, "probe not found"); - } - - if (await _agents.GetAsync(agentId, ct) is null) - { - return Result.Fail(MoShiroErrors.AgentNotFound, "agent not found"); - } - - return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct); - } - - private ProbeView View(ProbeRecord probe, IReadOnlyList agents) - { - var connections = new List(); - foreach ((NodeId agentId, ConnectedProbeInfo info) in _runtime.ConnectionsOf(probe.Id)) - { - AgentRecord? agent = agents.FirstOrDefault(a => a.Id == agentId); - if (agent is not null) - { - connections.Add(new ProbeConnection(agentId, agent.Name, _gateway.IsOnline(agentId), info.Version, info.Remote, info.SinceMs, info.IpVersion)); - } - } - - return new ProbeView(probe, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)], _probeRuntime.Get(probe.Id)); - } - - [GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")] - private static partial Regex NamePattern(); -} diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index 33936b6..b53ef08 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -16,9 +16,8 @@ public static class ApplicationServiceCollectionExtensions services.AddHostedService(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -31,7 +30,6 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index be820e0..36e5dc5 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -12,17 +12,17 @@ public sealed class BackendOptions /// 容器使用的 OCI repository namespace;Compose 默认 moshiro,开发镜像使用 moshiro-dev。 public string ImageNamespace { get; set; } = ""; - /// 节点镜像所在的仓库地址(管理端「添加 Agent / Probe」生成的容器命令用它拼镜像名)。 + /// 节点镜像所在的仓库地址(管理端「添加 Agent」生成的容器命令用它拼镜像名)。 public string NodeImageRegistry { get; set; } = "registry.oeo.one"; /// - /// 更新方式:restart(环境变量 MOSHIRO_UPDATE=restart,Quadlet 模板里写好了)表示管理员确认之后 Backend 有序退出,外面的服务管理器拉最新镜像再启动,和 Agent、Probe 一样; + /// 更新方式:restart(环境变量 MOSHIRO_UPDATE=restart,Quadlet 模板里写好了)表示管理员确认之后 Backend 有序退出,外面的服务管理器拉最新镜像再启动,和 Agent 一样; /// 留空就不能从管理端更新(别的部署方式退出之后不会重新拉取)。 /// public string? UpdateMode { get; set; } = Environment.GetEnvironmentVariable("MOSHIRO_UPDATE"); /// - /// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。 + /// 每个 Agent 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。 /// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。 /// public bool RequireOwner { get; set; } = true; @@ -98,7 +98,7 @@ public sealed class BackendOptions public double PhaseDurationScale { get; set; } = 1.0; /// - /// IP 归属查询服务的地址(`GET {地址}/lookup?ip=…`):Backend 用 Probe、Agent 的来源地址查一次国家和运营商, + /// IP 归属查询服务的地址(`GET {地址}/lookup?ip=…`):Backend 用 拨号方、Agent 的来源地址查一次国家和运营商, /// 只留下国家代码和运营商名,IP 不对外。留空 = 不查。服务的使用条款(公益、不能商用、保留署名)由部署的人自己遵守。 /// public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz"; diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 33ef79e..7ec6337 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -1,5 +1,6 @@ using Fleet.Core; using Fleet.Server; +using MoShiro.Protocol; namespace MoShiro.Backend.Application; @@ -10,8 +11,8 @@ public sealed record AgentRecord( byte[] PublicKey, byte[] TlsSpki, string PublicHost, - int ProbeTcpPort, - IReadOnlyList ProbeUdpPorts, + int TcpPort, + IReadOnlyList QuicPorts, IReadOnlyList Labels, bool PublicVisible, IReadOnlyList Kinds, @@ -30,7 +31,12 @@ public sealed record AgentRecord( bool Ipv6HostManual = false, IReadOnlyList? ReportedAddresses = null, byte[]? TlsPublicKey = null, - string? AddedBy = null); + string? AddedBy = null, + bool Listens = false) +{ + /// 可以作为拨号目标:激活、会监听、有公开地址和证书公钥(拨号方需要它固定证书、派生敲门密钥)。 + public bool Dialable => Status == NodeStatus.Active && Listens && PublicHost.Length > 0 && TlsPublicKey is { Length: > 0 }; +} /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -51,21 +57,16 @@ public sealed class AgentPatch public bool? PublicVisible { get; init; } } -/// 来自 fleet.hello 的信息。 +/// 来自 fleet.hello 的信息。不监听的节点没有 。 public sealed record AgentHello( IReadOnlyList Kinds, string? AgentVersion, string? LocalLimitsJson, - byte[]? TlsSpki, - int? ProbeTcpPort, - IReadOnlyList? ProbeUdpPorts, - bool Ipv6Supported = false, - string? Ipv6DiagnosticsJson = null, - IReadOnlyList? Addresses = null, - byte[]? TlsPublicKey = null); + ListenInfo? Listen, + string? Ipv6DiagnosticsJson = null); -/// 注册之后 MoShiro 的补充:公开地址和端口。 -public sealed record AgentEnrollment(string PublicHost, int ProbeTcpPort, IReadOnlyList ProbeUdpPorts, IReadOnlyList Kinds); +/// 注册之后 MoShiro 的补充:公开地址和端口(不监听的节点没有端口)。 +public sealed record AgentEnrollment(string PublicHost, int TcpPort, IReadOnlyList QuicPorts, IReadOnlyList Kinds); public sealed record JoinTokenInfo( string Id, @@ -79,9 +80,3 @@ public sealed record JoinTokenInfo( long CreatedAtMs, string Kind = "", string? CreatedBy = null); - -/// -/// 是连 Backend 的身份公钥, 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥; -/// Agent 的 peers 里放的是后者。 -/// -public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null); diff --git a/backend/src/MoShiro.Backend.Application/Models/TaskAndTestModels.cs b/backend/src/MoShiro.Backend.Application/Models/TaskAndTestModels.cs index 03936cc..a24a9e1 100644 --- a/backend/src/MoShiro.Backend.Application/Models/TaskAndTestModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/TaskAndTestModels.cs @@ -20,7 +20,7 @@ public sealed record TaskRecord( public sealed record TestRecord( TestId Id, NodeId AgentId, - ProbeId ProbeId, + NodeId DialerId, string Plan, string ParamsJson, string Trigger, diff --git a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfo.cs b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfo.cs index 1e57477..63f5019 100644 --- a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfo.cs +++ b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfo.cs @@ -1,12 +1,11 @@ namespace MoShiro.Backend.Application; /// -/// 一个节点(Agent 或 Probe)用来展示的信息:系统和架构、国家、运营商,以及它自己上报的构建信息(版本、tag、提交、构建时间)。 +/// 一个节点用来展示的信息:系统和架构、国家、运营商,以及它自己上报的构建信息(版本、tag、提交、构建时间)。 /// 不含任何 IP 地址、城市、ASN 或经纬度——这些只在查询的那一刻用到,不留下,也不对外。 /// public sealed record NodeInfo( string NodeId, - string Kind, string? Os, string? OsName, string? Arch, @@ -25,11 +24,5 @@ public sealed record NodeInfo( /// public sealed record NodeMeta(NodeInfo Info, string? GeoHost, long GeoAtMs, long UpdatedAtMs); -public static class NodeKinds -{ - public const string Agent = "agent"; - public const string Probe = "probe"; -} - /// IP 归属查询的结果(只取需要的几项)。 public sealed record GeoInfo(string? Country, int? Asn, string? Organization, bool IsPrivate); diff --git a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs index 70372b2..6f230f7 100644 --- a/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs +++ b/backend/src/MoShiro.Backend.Application/Nodes/NodeInfoService.cs @@ -8,7 +8,7 @@ namespace MoShiro.Backend.Application; /// /// 节点的展示信息:系统和架构(节点自报),国家和运营商(Backend 用节点的来源地址查一次)。 /// -/// Probe、Agent 不访问任何外部服务(客户的防火墙常常要审计),查询只由 Backend 发起。 +/// 拨号方、Agent 不访问任何外部服务(客户的防火墙常常要审计),查询只由 Backend 发起。 /// 只查公网地址:回环、内网、CGNAT、链路本地的地址一律不发出去。 /// 查询结果只留国家代码和运营商;IP 不对外,也不和展示信息一起返回。 /// @@ -36,7 +36,7 @@ public sealed class NodeInfoService _time = time; } - /// 所有节点的展示信息(Agent 和 Probe)。 + /// 所有节点的展示信息(Agent)。 public async ValueTask> ListAsync(CancellationToken ct) { await EnsureLoadedAsync(ct); @@ -47,10 +47,10 @@ public sealed class NodeInfoService /// 手动指定节点的运营商(telecom / unicom / mobile / broadnet / other);传 null 回到自动识别(下次节点出现时重新查)。 /// 返回 false 表示取值不认识。 /// - public async ValueTask SetIspAsync(string nodeId, string kind, string? key, CancellationToken ct) + public async ValueTask SetIspAsync(string nodeId, string? key, CancellationToken ct) { await EnsureLoadedAsync(ct); - NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind); + NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId); if (key is null) { // 清掉手动值,并让下一次心跳重新查询 @@ -69,7 +69,7 @@ public sealed class NodeInfoService } /// 节点自报的系统、架构和构建信息;没有变化就不写库。 - public async ValueTask SetPlatformAsync(string nodeId, string kind, PlatformInfo? platform, CancellationToken ct) + public async ValueTask SetPlatformAsync(string nodeId, PlatformInfo? platform, CancellationToken ct) { if (platform is null) { @@ -77,7 +77,7 @@ public sealed class NodeInfoService } await EnsureLoadedAsync(ct); - NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind); + NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId); string? os = Clean(platform.Os, 16); string? name = Clean(platform.Name, 40); string? arch = Clean(platform.Arch, 16); @@ -95,7 +95,7 @@ public sealed class NodeInfoService } /// Keep the displayed build version aligned with the authenticated Fleet hello, even if platform metadata is absent. - public async ValueTask SetVersionAsync(string nodeId, string kind, string? version, CancellationToken ct) + public async ValueTask SetVersionAsync(string nodeId, string? version, CancellationToken ct) { string? clean = Clean(version, 32); if (clean is null) @@ -104,7 +104,7 @@ public sealed class NodeInfoService } await EnsureLoadedAsync(ct); - NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind); + NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId); if (current.Info.Version == clean) { return; @@ -117,7 +117,7 @@ public sealed class NodeInfoService /// 节点从某个地址连上来了:查一次它的国家和运营商。已经查过同一个地址(并且没过期)就什么也不做; /// 私有地址不查;查询失败或服务关着,不影响别的,隔一阵再试。 /// - public async ValueTask ObserveAddressAsync(string nodeId, string kind, string? host, CancellationToken ct) + public async ValueTask ObserveAddressAsync(string nodeId, string? host, CancellationToken ct) { host = host?.Trim(); _lastTry[nodeId] = Now(); @@ -129,7 +129,7 @@ public sealed class NodeInfoService try { await EnsureLoadedAsync(ct); - NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId, kind); + NodeMeta current = _cache.TryGetValue(nodeId, out NodeMeta? found) ? found : Empty(nodeId); long now = Now(); bool same = string.Equals(current.GeoHost, host, StringComparison.OrdinalIgnoreCase); bool fresh = current.Info.Country is not null && now - current.GeoAtMs < s_geoTtl.TotalMilliseconds; @@ -175,14 +175,14 @@ public sealed class NodeInfoService return !_lastTry.TryGetValue(nodeId, out long at) || Now() - at >= s_retryAfter.TotalMilliseconds; } - /// Agent 报告某个 Probe 连着它:在后台用 Probe 的来源地址查国家和运营商(系统信息是 Probe 自己连上面板时报的)。出错只吞掉。 - public void ObserveProbeInBackground(string probeId, string? remote) => + /// Agent 报告某个 拨号方 连着它:在后台用 拨号方 的来源地址查国家和运营商(系统信息是 拨号方 自己连上面板时报的)。出错只吞掉。 + public void ObserveDialerInBackground(string dialerId, string? remote) => _ = Task.Run(async () => { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - await ObserveAddressAsync(probeId, NodeKinds.Probe, remote, cts.Token); + await ObserveAddressAsync(dialerId, remote, cts.Token); } catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException) { @@ -197,8 +197,8 @@ public sealed class NodeInfoService try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - await SetPlatformAsync(agentId, NodeKinds.Agent, platform, cts.Token); - await ObserveAddressAsync(agentId, NodeKinds.Agent, publicHost, cts.Token); + await SetPlatformAsync(agentId, platform, cts.Token); + await ObserveAddressAsync(agentId, publicHost, cts.Token); } catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException or IOException) { @@ -272,7 +272,7 @@ public sealed class NodeInfoService private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds(); - private static NodeMeta Empty(string nodeId, string kind) => new(new NodeInfo(nodeId, kind, null, null, null, null, null, null), null, 0, 0); + private static NodeMeta Empty(string nodeId) => new(new NodeInfo(nodeId, null, null, null, null, null, null), null, 0, 0); /// 节点自报的字符串只用于展示:去掉首尾空白,截断,没有内容就当没有。 private static string? Clean(string? text, int max) diff --git a/backend/src/MoShiro.Backend.Application/Ports/IAgentGateway.cs b/backend/src/MoShiro.Backend.Application/Ports/IAgentGateway.cs index 5325d4e..92b5a93 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/IAgentGateway.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/IAgentGateway.cs @@ -10,7 +10,7 @@ public interface IAgentGateway { IReadOnlyCollection OnlineAgents { get; } - /// 所有在线的节点(Agent 和 Probe)。 + /// 所有在线的节点。 IReadOnlyCollection OnlineNodes { get; } bool IsOnline(NodeId agent); @@ -20,18 +20,18 @@ public interface IAgentGateway void NotifyAllTaskSetsChanged(); - /// 失败时错误码为 probe_offline、test_busy、agent_offline 等。 + /// 失败时错误码为 dialer_offline、test_busy、agent_offline 等。 ValueTask> RunTestAsync(NodeId agent, TestRunParams run, CancellationToken ct); ValueTask CancelTestAsync(NodeId agent, TestId test, CancellationToken ct); - /// 让 Agent 断开某个 Probe 的会话。返回 Agent 是否真的有这个会话。 - ValueTask> DisconnectProbeAsync(NodeId agent, ProbeId probe, CancellationToken ct); + /// 让监听方断开某个拨号方的会话。返回监听方是否真的有这个会话。 + ValueTask> DisconnectDialerAsync(NodeId listener, NodeId dialer, CancellationToken ct); ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct); - /// Notify an online Probe to immediately retry all IP paths to an Agent. - ValueTask NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) => ValueTask.FromResult(false); + /// Notify an online dialer to immediately retry all IP paths to a listener. + ValueTask NotifyLinkRetryAsync(NodeId dialer, NodeId listener, CancellationToken ct) => ValueTask.FromResult(false); /// 把发布清单和签名原样交给节点(通知,不等回复)。 ValueTask NotifyUpdateOfferAsync(NodeId node, string manifest, string signature, CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index b5f7aa8..37af7ea 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -29,6 +29,8 @@ public interface IAgentStore /// 写入自动识别出的地址;管理员手填的那一项不动。为 null 的项不改。 ValueTask ApplyAutoAddressesAsync(NodeId id, string? publicHost, string? ipv6Host, CancellationToken ct); + ValueTask SetIpv6DiagnosticsAsync(NodeId id, string diagnosticsJson, CancellationToken ct); + ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct); ValueTask GetTaskHashAsync(NodeId id, CancellationToken ct); @@ -66,23 +68,6 @@ public interface IJoinTokenStore ValueTask DeleteAsync(string id, CancellationToken ct); } -public interface IProbeStore -{ - ValueTask> ListAsync(CancellationToken ct); - - ValueTask GetAsync(string id, CancellationToken ct); - - /// 新增或整条更新(轮换密钥、改名都走这里)。 - ValueTask SaveAsync(ProbeRecord probe, CancellationToken ct); - - ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct); - - /// 吊销:保留记录,公钥立即从所有 Agent 的名单里移除。已经吊销的返回 false。 - ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct); - - ValueTask DeleteAsync(string id, CancellationToken ct); -} - public interface ISettingsStore { ValueTask GetAsync(string key, CancellationToken ct); @@ -114,15 +99,15 @@ public interface ITestStore ValueTask GetAsync(TestId id, CancellationToken ct); - ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct); + ValueTask> ListAsync(NodeId? agentId, NodeId? dialerId, int limit, CancellationToken ct); - ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct); + ValueTask> ListAsync(NodeId? agentId, NodeId? dialerId, int limit, string? ipVersion, CancellationToken ct); ValueTask UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct); ValueTask SetPhaseCountAsync(TestId id, int phaseCount, CancellationToken ct); - ValueTask HasActiveAsync(NodeId agentId, ProbeId probeId, CancellationToken ct); + ValueTask HasActiveAsync(NodeId agentId, NodeId dialerId, CancellationToken ct); ValueTask SaveResultAsync(TestResultRecord result, CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Application/Query/Lines.cs b/backend/src/MoShiro.Backend.Application/Query/Lines.cs index c5b1d9b..c7d02c3 100644 --- a/backend/src/MoShiro.Backend.Application/Query/Lines.cs +++ b/backend/src/MoShiro.Backend.Application/Query/Lines.cs @@ -1,14 +1,15 @@ using Fleet.Core; +using Fleet.Server; using MoShiro.Protocol; namespace MoShiro.Backend.Application; -/// 线路按(Agent,Probe,地址族)隔离。IPv4 保留 agent~probe 和 line:probe;IPv6 分别追加 ~ipv6、:ipv6。 -public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVersion = IpVersions.V4) +/// 线路按(监听方,拨号方,地址族)隔离。IPv4 是 listener~dialer 和 line:dialer;IPv6 分别追加 ~ipv6、:ipv6。 +public readonly record struct LineKey(NodeId Listener, NodeId Dialer, string IpVersion = IpVersions.V4) { - public string Series => IpVersions.LineSeries(Probe.Value, IpVersion); + public string Series => IpVersions.LineSeries(Dialer.Value, IpVersion); - public string Id => Agent.Value + "~" + Probe.Value + (IpVersion == IpVersions.V6 ? "~ipv6" : ""); + public string Id => Listener.Value + "~" + Dialer.Value + (IpVersion == IpVersions.V6 ? "~ipv6" : ""); public static bool TryParse(string text, out LineKey key) { @@ -19,52 +20,49 @@ public readonly record struct LineKey(NodeId Agent, ProbeId Probe, string IpVers return false; } - key = new LineKey(new NodeId(parts[0]), new ProbeId(parts[1]), parts.Length == 3 ? IpVersions.V6 : IpVersions.V4); + key = new LineKey(new NodeId(parts[0]), new NodeId(parts[1]), parts.Length == 3 ? IpVersions.V6 : IpVersions.V4); return true; } } -/// 一条线路的展示视图:Agent 的状态、Probe 记录,以及这一对现在是否连着。 -public sealed record LineView(AgentView Agent, ProbeRecord Probe, string IpVersion = IpVersions.V4, ProbeQuicLinkSnapshot? QuicHealth = null) +/// 一条线路的展示视图:监听方的状态、拨号方记录,以及这一对现在是否连着。 +public sealed record LineView(AgentView Listener, AgentRecord Dialer, string IpVersion = IpVersions.V4, ReportedLink? Link = null) { - public LineKey Key => new(Agent.Agent.Id, new ProbeId(Probe.Id), IpVersion); + public LineKey Key => new(Listener.Agent.Id, Dialer.Id, IpVersion); - public bool Connected => Agent.Probes.Any(p => p.Id == Probe.Id && p.IpVersion == IpVersion); + public bool Connected => Listener.Dialers.Any(d => d.Id == Dialer.Id.Value && d.IpVersion == IpVersion); - public ConnectedProbeInfo? Connection => Agent.Probes.FirstOrDefault(p => p.Id == Probe.Id && p.IpVersion == IpVersion); + public ConnectedDialerInfo? Connection => Listener.Dialers.FirstOrDefault(d => d.Id == Dialer.Id.Value && d.IpVersion == IpVersion); } -/// 线路目录:把 id 解析成(Agent,Probe),列出所有线路。 +/// 线路目录:把 id 解析成(监听方,拨号方),列出所有线路。 public sealed class LineService { private readonly AgentService _agents; - private readonly IProbeStore _probes; private readonly LinkService _links; - private readonly ProbeQuicRuntime _quicRuntime; + private readonly LinkRuntime _linkRuntime; - public LineService(AgentService agents, IProbeStore probes, LinkService links, ProbeQuicRuntime quicRuntime) + public LineService(AgentService agents, LinkService links, LinkRuntime linkRuntime) { _links = links; _agents = agents; - _probes = probes; - _quicRuntime = quicRuntime; + _linkRuntime = linkRuntime; } - /// 所有线路:每个 Agent 和每个未吊销的 Probe 配成一对,只留下线路开着的(见 );关掉的线路历史数据还在,按 id 仍然查得到。 + /// 所有线路:每个监听节点和每个激活的节点配成一对,只留下线路开着的(见 );关掉的线路历史数据还在,按 id 仍然查得到。 public async ValueTask> ListAsync(Func include, CancellationToken ct) { - IReadOnlyList agents = await _agents.ListAsync(ct); - IReadOnlyList probes = [.. (await _probes.ListAsync(ct)).Where(p => p.RevokedAtMs is null)]; + IReadOnlyList nodes = await _agents.ListAsync(ct); LinkSnapshot links = await _links.SnapshotAsync(ct); var result = new List(); - foreach (AgentView agent in agents.Where(include)) + foreach (AgentView listener in nodes.Where(n => n.Agent.Listens && include(n))) { - foreach (ProbeRecord probe in probes.Where(p => links.IsLinked(p.Id, agent.Agent.Id.Value))) + foreach (AgentRecord dialer in nodes.Select(n => n.Agent).Where(a => a.Status == NodeStatus.Active && links.IsLinked(a, listener.Agent))) { - result.Add(new LineView(agent, probe, IpVersions.V4, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V4))); - if (agent.Agent.Ipv6Host.Length > 0) + result.Add(new LineView(listener, dialer, IpVersions.V4, _linkRuntime.Get(dialer.Id.Value, listener.Agent.Id.Value, IpVersions.V4))); + if (listener.Agent.Ipv6Host.Length > 0) { - result.Add(new LineView(agent, probe, IpVersions.V6, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, IpVersions.V6))); + result.Add(new LineView(listener, dialer, IpVersions.V6, _linkRuntime.Get(dialer.Id.Value, listener.Agent.Id.Value, IpVersions.V6))); } } } @@ -73,12 +71,12 @@ public sealed class LineService } /// - /// 线路 id 是 agent~probe。只给 Agent id 时:恰好有一个未吊销的 Probe 就用它,否则要求明确指定(probe_required)。 + /// 线路 id 是 listener~dialer。只给监听方 id 时:当前恰好有一条开着的线路就用它,否则要求明确指定(dialer_required)。 /// public async ValueTask> ResolveAsync(string id, CancellationToken ct) { - string agentPart = id; - string? probePart = null; + string listenerPart = id; + string? dialerPart = null; string ipVersion = IpVersions.V4; if (id.Contains('~', StringComparison.Ordinal)) { @@ -87,38 +85,38 @@ public sealed class LineService return Result.Fail(MoShiroErrors.AgentNotFound, "line not found"); } - agentPart = key.Agent.Value; - probePart = key.Probe.Value; + listenerPart = key.Listener.Value; + dialerPart = key.Dialer.Value; ipVersion = key.IpVersion; } - AgentView? agent = await _agents.GetAsync(new NodeId(agentPart), ct); - if (agent is null) + AgentView? listener = await _agents.GetAsync(new NodeId(listenerPart), ct); + if (listener is null) { return Result.Fail(MoShiroErrors.AgentNotFound, "line not found"); } - IReadOnlyList probes = await _probes.ListAsync(ct); - ProbeRecord? probe; - if (probePart is not null) + AgentRecord? dialer; + if (dialerPart is not null) { - probe = probes.FirstOrDefault(p => p.Id == probePart); - if (probe is null) + dialer = (await _agents.ListAsync(ct)).Select(n => n.Agent).FirstOrDefault(a => a.Id.Value == dialerPart); + if (dialer is null) { - return Result.Fail(MoShiroErrors.ProbeNotFound, "probe not found"); + return Result.Fail(MoShiroErrors.AgentNotFound, "dialer not found"); } } else { - ProbeRecord[] active = [.. probes.Where(p => p.RevokedAtMs is null)]; - if (active.Length != 1) + LinkSnapshot links = await _links.SnapshotAsync(ct); + AgentRecord[] linked = [.. (await _agents.ListAsync(ct)).Select(n => n.Agent).Where(a => a.Status == NodeStatus.Active && links.IsLinked(a, listener.Agent))]; + if (linked.Length != 1) { - return Result.Fail(MoShiroErrors.ProbeRequired, active.Length == 0 ? "there is no active probe" : "several probes exist: use ~ as the line id"); + return Result.Fail(MoShiroErrors.DialerRequired, linked.Length == 0 ? "there is no line to this node" : "several lines exist: use ~ as the line id"); } - probe = active[0]; + dialer = linked[0]; } - return Result.Ok(new LineView(agent, probe, ipVersion, _quicRuntime.Get(probe.Id, agent.Agent.Id.Value, ipVersion))); + return Result.Ok(new LineView(listener, dialer, ipVersion, _linkRuntime.Get(dialer.Id.Value, listener.Agent.Id.Value, ipVersion))); } } diff --git a/backend/src/MoShiro.Backend.Application/Query/QueryService.cs b/backend/src/MoShiro.Backend.Application/Query/QueryService.cs index 8bb0b7b..0bf4a61 100644 --- a/backend/src/MoShiro.Backend.Application/Query/QueryService.cs +++ b/backend/src/MoShiro.Backend.Application/Query/QueryService.cs @@ -58,9 +58,9 @@ public sealed class QueryService // ───────────────────────── 单条线路的时序 ───────────────────────── - /// 这条线路现在是不是连着(Agent 报告了这个 Probe 的会话):连着的线路窗口以当前时间为终点,不去查“最后一个样本”——Backend 刚重启、热层和持久层还没追上时,后者会落后好几个小时,页面就显示成旧数据。 + /// 这条线路现在是不是连着(监听方报告了这个拨号方的会话):连着的线路窗口以当前时间为终点,不去查“最后一个样本”——Backend 刚重启、热层和持久层还没追上时,后者会落后好几个小时,页面就显示成旧数据。 private bool IsConnected(LineKey line) => - _runtime.TryGet(line.Agent, out AgentRuntimeState? state) && state.Probes.Any(p => p.Id == line.Probe.Value && p.IpVersion == line.IpVersion); + _runtime.TryGet(line.Listener, out AgentRuntimeState? state) && state.Dialers.Any(p => p.Id == line.Dialer.Value && p.IpVersion == line.IpVersion); public async ValueTask> SeriesAsync(LineKey line, RangeRequest request, MetricSelection selection, CancellationToken ct) { @@ -70,7 +70,7 @@ public sealed class QueryService && !IsConnected(line)) { long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); - if (await _loader.LatestSampleMsAsync(line.Agent, line.Series, now, ct) is { } latest && latest < now - 5000) + if (await _loader.LatestSampleMsAsync(line.Listener, line.Series, now, ct) is { } latest && latest < now - 5000) { request = request with { Range = null, From = DateTimeOffset.FromUnixTimeMilliseconds(latest - span), To = DateTimeOffset.FromUnixTimeMilliseconds(latest) }; } @@ -82,7 +82,7 @@ public sealed class QueryService return Result.Fail(error); } - Result> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct); + Result> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Listener, line.Series, ct); if (!loaded.TryGet(out IReadOnlyList? rows, out error)) { return Result.Fail(error); @@ -124,7 +124,7 @@ public sealed class QueryService SummaryResult summary = Summarize(bucketed.Merge(), bucketed.TotalRows, p.Plan, p.Range, selection, overlay); long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds(); - IReadOnlyList events = await _events.ListAsync(line.Agent, null, p.Range.FromMs, p.Range.ToMs, null, 100, ct); + IReadOnlyList events = await _events.ListAsync(line.Listener, null, p.Range.FromMs, p.Range.ToMs, null, 100, ct); IReadOnlyList gaps = FindGaps(bucketed, p.Range, nowMs, events); var versions = new Dictionary(StringComparer.Ordinal); foreach (string id in selection.ExportIds) @@ -140,7 +140,7 @@ public sealed class QueryService /// 这条线路最早的数据(Unix 毫秒);没有数据返回 null。 public ValueTask EarliestAsync(LineKey line, CancellationToken ct) => - _loader.EarliestSampleMsAsync(line.Agent, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + _loader.EarliestSampleMsAsync(line.Listener, line.Series, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); // ───────────────────────── 首页排行 ───────────────────────── @@ -174,7 +174,7 @@ public sealed class QueryService } IReadOnlyDictionary overlay = selection.WantsScores ? await TestOverlayAsync(line.Key, ct) : new Dictionary(); - IReadOnlyList recent = await _tests.ListAsync(line.Key.Agent, line.Key.Probe, 1, line.Key.IpVersion, ct); + IReadOnlyList recent = await _tests.ListAsync(line.Key.Listener, line.Key.Dialer, 1, line.Key.IpVersion, ct); result.Add(new OverviewLine(line, Summarize(merged, count, p.Plan, p.Range, selection, overlay), recent.Count > 0 ? recent[0] : null)); } @@ -245,7 +245,7 @@ public sealed class QueryService return Result.Fail(error); } - Result> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct); + Result> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Listener, line.Series, ct); if (!loaded.TryGet(out IReadOnlyList? rows, out error)) { return Result.Fail(error); @@ -289,7 +289,7 @@ public sealed class QueryService return Result.Fail(error); } - Result> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Agent, line.Series, ct); + Result> loaded = await _loader.LoadAsync(p.Plan, p.Range, line.Listener, line.Series, ct); if (!loaded.TryGet(out IReadOnlyList? rows, out error)) { return Result.Fail(error); @@ -379,7 +379,7 @@ public sealed class QueryService long cutoff = _time.GetUtcNow().ToUnixTimeMilliseconds() - (7 * Steps.Day); var overlay = new Dictionary(StringComparer.Ordinal); var seenPlans = new HashSet(); - foreach (TestRecord test in await _tests.ListAsync(line.Agent, line.Probe, 20, line.IpVersion, ct)) + foreach (TestRecord test in await _tests.ListAsync(line.Listener, line.Dialer, 20, line.IpVersion, ct)) { if (test.Status != TestStatuses.Succeeded || test.FinishedAtMs is not { } finished || finished < cutoff || !seenPlans.Add(test.Plan)) { @@ -422,7 +422,7 @@ public sealed class QueryService { long from = bucketed.T[start]; long to = bucketed.T[i - 1] + range.StepMs; - EventRecord? near = events.FirstOrDefault(e => e.AtMs >= from - range.StepMs && e.AtMs <= to && e.Kind is "probe_disconnected" or "agent_offline" or "clock_step" or "gap" or "cpu_saturated"); + EventRecord? near = events.FirstOrDefault(e => e.AtMs >= from - range.StepMs && e.AtMs <= to && e.Kind is "dialer_disconnected" or "agent_offline" or "clock_step" or "gap" or "cpu_saturated"); gaps.Add(new GapInfo(from, to, near?.Kind ?? "no_data")); } } diff --git a/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs b/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs index f0e562c..40d1ea3 100644 --- a/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs +++ b/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs @@ -130,7 +130,7 @@ public sealed class RowLoader { foreach (LineKey line in lines) { - Result> one = await LoadAsync(plan, range, line.Agent, line.Series, ct); + Result> one = await LoadAsync(plan, range, line.Listener, line.Series, ct); if (!one.TryGet(out IReadOnlyList? rows, out FleetError? error)) { return Result>>.Fail(error); @@ -142,14 +142,14 @@ public sealed class RowLoader return Result>>.Ok(result); } - // 每个 Probe 一个序列:按序列分组,每组一次查询 + // 每个拨号方一个序列:按序列分组,每组一次查询 foreach (IGrouping bySeries in lines.GroupBy(l => l.Series)) { - NodeId[] agents = [.. bySeries.Select(l => l.Agent).Distinct()]; + NodeId[] agents = [.. bySeries.Select(l => l.Listener).Distinct()]; IReadOnlyList all = await _metrics.ReadAgentsAsync(ToLevel(plan.Source), agents, bySeries.Key, range.FromMs, range.ToMs, ct); foreach (IGrouping group in all.GroupBy(r => r.AgentId)) { - LineKey key = bySeries.First(l => l.Agent == group.Key); + LineKey key = bySeries.First(l => l.Listener == group.Key); result[key] = Decode([.. group]); } } diff --git a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs index dfe7cc0..1ff2a83 100644 --- a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs +++ b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs @@ -9,28 +9,26 @@ namespace MoShiro.Backend.Application; public sealed record DesiredTaskSet(long Version, TaskSet Document); /// -/// 为每个 Agent 计算期望状态():有效任务集 + 允许连接的 Probe 公钥。 +/// 为每个 Agent 计算期望状态():有效任务集 + 允许拨入的拨号方公钥。 /// 只有内容真的变了才递增版本号,所以断线重连后 Agent 收到的永远是当前版本的完整快照。 /// public sealed class TaskPlanner : IDisposable { - public const string ProbeAgentsVersionKey = "probe_agents_version"; + public const string DialTargetsVersionKey = "dial_targets_version"; private readonly ITaskStore _tasks; private readonly IAgentStore _agents; - private readonly IProbeStore _probes; private readonly ISettingsStore _settings; private readonly LinkService _links; private readonly TrustService _trust; private readonly SemaphoreSlim _gate = new(1, 1); - public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links, TrustService trust) + public TaskPlanner(ITaskStore tasks, IAgentStore agents, ISettingsStore settings, LinkService links, TrustService trust) { _trust = trust; _links = links; _tasks = tasks; _agents = agents; - _probes = probes; _settings = settings; } @@ -44,7 +42,7 @@ public sealed class TaskPlanner : IDisposable } IReadOnlyList all = await _tasks.ListAsync(ct); - IReadOnlyList probes = await _probes.ListAsync(ct); + IReadOnlyList nodes = await _agents.ListAsync(ct); LinkSnapshot links = await _links.SnapshotAsync(ct); var document = new TaskSet(); @@ -62,9 +60,12 @@ public sealed class TaskPlanner : IDisposable }); } - foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value))) + if (agent.Listens) { - document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) }); + foreach (AgentRecord dialer in nodes.Where(n => n.Status == NodeStatus.Active && n.TlsPublicKey is { Length: > 0 } && links.IsLinked(n, agent))) + { + document.Peers.Add(new PeerSpec { NodeId = dialer.Id, PublicKey = Convert.ToBase64String(dialer.TlsPublicKey!) }); + } } IReadOnlyList chain = await _trust.ChainAsync(ct); @@ -96,5 +97,5 @@ public sealed class TaskPlanner : IDisposable public void Dispose() => _gate.Dispose(); - public ValueTask BumpProbeAgentsVersionAsync(CancellationToken ct) => _settings.IncrementAsync(ProbeAgentsVersionKey, ct); + public ValueTask BumpDialTargetsVersionAsync(CancellationToken ct) => _settings.IncrementAsync(DialTargetsVersionKey, ct); } diff --git a/backend/src/MoShiro.Backend.Application/Tests/Plans.cs b/backend/src/MoShiro.Backend.Application/Tests/Plans.cs index 2d4d1e1..f4c72da 100644 --- a/backend/src/MoShiro.Backend.Application/Tests/Plans.cs +++ b/backend/src/MoShiro.Backend.Application/Tests/Plans.cs @@ -7,7 +7,7 @@ namespace MoShiro.Backend.Application; public sealed record PhaseSpec(string Kind, IReadOnlyDictionary Overrides, int DurationSeconds); /// -/// 测试方案:业务层的概念,只在 Backend。方案只是若干采集原语(Kind)阶段的编排,采集层(Agent、Probe、Measurement)不认识它。 +/// 测试方案:业务层的概念,只在 Backend。方案只是若干采集原语(Kind)阶段的编排,采集层(Agent、Measurement)不认识它。 /// 测试方案:多个阶段的 once 任务组合(例如 hy2_ladder 在 10、25、50、100 Mbps 下各跑 10 秒)。 public sealed record PlanSpec(string Id, string Title, string Description, IReadOnlyList Phases); diff --git a/backend/src/MoShiro.Backend.Application/Tests/TestService.cs b/backend/src/MoShiro.Backend.Application/Tests/TestService.cs index bcc78f2..7544418 100644 --- a/backend/src/MoShiro.Backend.Application/Tests/TestService.cs +++ b/backend/src/MoShiro.Backend.Application/Tests/TestService.cs @@ -28,10 +28,10 @@ public sealed class TestService _time = time; } - public ValueTask> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, CancellationToken ct) => - StartAsync(agentId, probeId, request, trigger, IpVersions.V4, ct); + public ValueTask> StartAsync(NodeId agentId, NodeId dialerId, StartTestRequest request, string trigger, CancellationToken ct) => + StartAsync(agentId, dialerId, request, trigger, IpVersions.V4, ct); - public async ValueTask> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, string ipVersion, CancellationToken ct) + public async ValueTask> StartAsync(NodeId agentId, NodeId dialerId, StartTestRequest request, string trigger, string ipVersion, CancellationToken ct) { if (!IpVersions.IsValid(ipVersion)) { @@ -78,12 +78,12 @@ public sealed class TestService return Result.Fail(MoShiroErrors.AgentOffline, "agent is offline"); } - if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value, ipVersion))) + if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(dialerId.Value, ipVersion))) { - return Result.Fail(MoShiroErrors.ProbeOffline, $"probe {probeId.Value} has no session with this agent"); + return Result.Fail(MoShiroErrors.DialerOffline, $"dialer {dialerId.Value} has no session with this node"); } - if (await _tests.HasActiveAsync(agentId, probeId, ct)) + if (await _tests.HasActiveAsync(agentId, dialerId, ct)) { return Result.Fail(MoShiroErrors.TestBusy, "another test is already running on this line"); } @@ -91,10 +91,10 @@ public sealed class TestService var testId = new TestId(Ulid.NewPrefixed(TestId.Prefix, _time)); long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); string paramsJson = request.Params is null ? "{}" : JsonSerializer.Serialize(new Dictionary(request.Params), TestJson.Default.ParamMap); - var record = new TestRecord(testId, agentId, probeId, plan.Id, paramsJson, trigger, TestStatuses.Queued, null, null, phaseList.Count, now, null, null, ipVersion); + var record = new TestRecord(testId, agentId, dialerId, plan.Id, paramsJson, trigger, TestStatuses.Queued, null, null, phaseList.Count, now, null, null, ipVersion); await _tests.CreateAsync(record, ct); - Result sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, ProbeId = probeId, IpVersion = ipVersion, Plan = plan.Id, Phases = phaseList }, ct); + Result sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, DialerId = dialerId, IpVersion = ipVersion, Plan = plan.Id, Phases = phaseList }, ct); if (!sent.TryGet(out _, out FleetError? sendError)) { await _tests.UpdateStatusAsync(testId, TestStatuses.Failed, null, sendError.Code.Value, now, ct); @@ -160,9 +160,9 @@ public sealed class TestService return new TestDetail(test, plan?.Phases.Select(p => p.Kind).ToList() ?? [], stored is null ? null : ToView(stored)); } - public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) => _tests.ListAsync(agentId, probeId, limit, ct); + public ValueTask> ListAsync(NodeId? agentId, NodeId? dialerId, int limit, CancellationToken ct) => _tests.ListAsync(agentId, dialerId, limit, ct); - public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct) => _tests.ListAsync(agentId, probeId, limit, ipVersion, ct); + public ValueTask> ListAsync(NodeId? agentId, NodeId? dialerId, int limit, string? ipVersion, CancellationToken ct) => _tests.ListAsync(agentId, dialerId, limit, ipVersion, ct); public static TestResultView ToView(TestResultRecord stored) { @@ -240,9 +240,9 @@ public sealed class TestService } // 没有指定端口的 UDP 阶段使用 Agent 上报的数据端口(目录里的默认值只是出厂默认,每个 Agent 可以改) - if (!merged.ContainsKey("port") && kind.Blocks.Transport == "quic_datagram" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0) + if (!merged.ContainsKey("port") && kind.Blocks.Transport == "quic_datagram" && kind.Params.Any(s => s.Name == "port") && agent.QuicPorts.Count > 0) { - merged["port"] = JsonSerializer.SerializeToElement(agent.ProbeUdpPorts[0], PlanPortJson.Default.Int32); + merged["port"] = JsonSerializer.SerializeToElement(agent.QuicPorts[0], PlanPortJson.Default.Int32); } ParamResolution resolved = ParamResolver.Resolve(kind.Params, merged); diff --git a/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs b/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs index 9ec5a88..13ae3e7 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs @@ -21,7 +21,7 @@ public interface IDatabaseBackup public sealed record BackendUpdateView(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseOffer? Available, string? Error); /// -/// Backend 的发布通知和确认,和 Agent、Probe 是同一种更新:面板登记 CI 签名的清单,管理员确认之后 Backend 有序退出, +/// Backend 的发布通知和确认,和 Agent 是同一种更新:面板登记 CI 签名的清单,管理员确认之后 Backend 有序退出, /// Quadlet 单元每次启动都先拉最新镜像(Pull=always),所以退出再被拉起就是更新。Backend 自己不下载、不替换任何东西。 /// 只有设了环境变量 MOSHIRO_UPDATE=restart(Quadlet 模板里写好了)才启用;别的部署方式退出之后不会重新拉取。 /// 只提示不自动重启:Backend 指挥所有节点,重启会让它们全部断一两分钟。 diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleasePoller.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleasePoller.cs index 71f9282..9d31e54 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleasePoller.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleasePoller.cs @@ -5,14 +5,14 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// -/// 定时去镜像仓库取各组件(Agent、Probe、Backend 自己)最新的签名清单:CI 把清单登记给面板那一步是尽力而为的(网络不通只警告), +/// 定时去镜像仓库取各组件(Agent、Backend 自己)最新的签名清单:CI 把清单登记给面板那一步是尽力而为的(网络不通只警告), /// 面板自己也去仓库取,Backend 的更新通知和节点的更新建议就不依赖那一步。取回来的清单要用发布公钥验过签才登记。 /// public sealed partial class ReleasePoller : BackgroundService { private static readonly TimeSpan s_startupDelay = TimeSpan.FromSeconds(20); private static readonly TimeSpan s_interval = TimeSpan.FromMinutes(5); - private static readonly string[] s_components = [NodeKinds.Agent, NodeKinds.Probe, ReleaseService.BackendComponent]; + private static readonly string[] s_components = [ReleaseService.AgentComponent, ReleaseService.BackendComponent]; private readonly ReleaseService _releases; private readonly TimeProvider _time; diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index b74c817..555f811 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -11,7 +11,7 @@ namespace MoShiro.Backend.Application; public sealed record ReleaseOffer(string Component, long Seq, string Tag, string Version, string Digest, string Manifest, string Signature, string Channel = "release"); /// -/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的 Agent、Probe(节点连上时再补发一次)。 +/// 发布清单的分发:CI 签名之后推给面板,面板存下来并原样转给在线的节点(节点连上时再补发一次)。 /// 登记入口(/api/release/offer)只收带有效 CI 签名的清单;面板不决定拉什么:节点宿主机上的更新器用本机的发布公钥验签,并且只认本机配置的镜像仓库; /// 所以面板即使被攻破,也推不出一个能被装上的假镜像。面板只负责「通知」和「请求更新」,状态由节点随心跳报回来。 /// @@ -20,7 +20,8 @@ public sealed partial class ReleaseService : IDisposable public const int MaxManifestBytes = 4096; public const int MaxSignatureBytes = 512; public const string BackendComponent = "backend"; - private static readonly string[] s_components = [NodeKinds.Agent, NodeKinds.Probe, BackendComponent]; + public const string AgentComponent = "agent"; + private static readonly string[] s_components = [AgentComponent, BackendComponent]; private readonly ISettingsStore _settings; private readonly IAgentGateway _gateway; @@ -116,7 +117,7 @@ public sealed partial class ReleaseService : IDisposable /// 不再信任某把密钥之后(删除自有发布密钥):已登记的清单里,现在验不过的丢掉,免得继续分发。 public async ValueTask PurgeUnverifiedAsync(CancellationToken ct) { - foreach (string key in new[] { NodeKinds.Agent, NodeKinds.Probe, BackendComponent + ".dev", BackendComponent + ".release" }) + foreach (string key in new[] { AgentComponent, BackendComponent + ".dev", BackendComponent + ".release" }) { string? manifest = await _settings.GetAsync(Key(key, "manifest"), ct); string? signature = await _settings.GetAsync(Key(key, "signature"), ct); @@ -188,9 +189,7 @@ public sealed partial class ReleaseService : IDisposable private IEnumerable OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component); internal static string? ComponentOf(NodeId node) => - node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? NodeKinds.Agent - : node.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? NodeKinds.Probe - : null; + node.Value.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? AgentComponent : null; private static string Key(string component, string part) => $"release.{component}.{part}"; diff --git a/backend/src/MoShiro.Backend.Gateway/DialTargetsStateProvider.cs b/backend/src/MoShiro.Backend.Gateway/DialTargetsStateProvider.cs new file mode 100644 index 0000000..7ca1611 --- /dev/null +++ b/backend/src/MoShiro.Backend.Gateway/DialTargetsStateProvider.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using Fleet.Rpc; +using Fleet.Server; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Gateway; + +/// 期望状态:为每个节点下发它要拨出的监听节点列表(地址、端口、证书公钥)。节点集合或线路变化时由 NotifyAllChanged 一起重算推送。 +public sealed class DialTargetsStateProvider : IStateProvider +{ + private readonly AgentService _agents; + private readonly IAgentStore _store; + + public DialTargetsStateProvider(AgentService agents, IAgentStore store) + { + _agents = agents; + _store = store; + } + + public string Type => DialTargets.StateType; + + public async ValueTask GetAsync(NodeRecord node, CancellationToken ct) + { + if (await _store.GetAsync(node.Id, ct) is not { Status: NodeStatus.Active } dialer) + { + return null; + } + + DialTargets targets = await _agents.DialTargetsAsync(dialer, ct); + return new StateDocument(targets.Version, JsonSerializer.SerializeToElement(targets, MoShiroJsonContext.Default.DialTargets)); + } + + public ValueTask OnAppliedAsync(NodeRecord node, long version, StateSyncResult result, CancellationToken ct) => ValueTask.CompletedTask; +} diff --git a/backend/src/MoShiro.Backend.Gateway/FleetGateway.cs b/backend/src/MoShiro.Backend.Gateway/FleetGateway.cs index a257f1e..3b5574a 100644 --- a/backend/src/MoShiro.Backend.Gateway/FleetGateway.cs +++ b/backend/src/MoShiro.Backend.Gateway/FleetGateway.cs @@ -50,10 +50,10 @@ public sealed class FleetGateway : IAgentGateway await Connections.RequestAsync( agent, MoShiroMethods.TestCancel, new TestCancelParams { TestId = test }, MoShiroJsonContext.Default.TestCancelParams, MoShiroJsonContext.Default.TestRunResult, ct); - public async ValueTask> DisconnectProbeAsync(NodeId agent, ProbeId probe, CancellationToken ct) + public async ValueTask> DisconnectDialerAsync(NodeId listener, NodeId dialer, CancellationToken ct) { Result result = await Connections.RequestAsync( - agent, MoShiroMethods.ProbeDisconnect, new ProbeDisconnectParams { ProbeId = probe }, MoShiroJsonContext.Default.ProbeDisconnectParams, MoShiroJsonContext.Default.TestRunResult, ct); + listener, MoShiroMethods.DialerDisconnect, new DialerDisconnectParams { DialerId = dialer }, MoShiroJsonContext.Default.DialerDisconnectParams, MoShiroJsonContext.Default.TestRunResult, ct); if (!result.TryGet(out TestRunResult? done, out FleetError? error)) { return Result.Fail(error.Code == FleetErrors.NodeOffline ? MoShiroErrors.AgentOffline : error.Code, error.Message); @@ -64,8 +64,8 @@ public sealed class FleetGateway : IAgentGateway public ValueTask DisconnectAsync(NodeId agent, string reason, CancellationToken ct) => Connections.DisconnectAsync(agent, reason, ct); - public ValueTask NotifyProbeLinkRetryAsync(NodeId probe, NodeId agent, CancellationToken ct) => - Connections.NotifyAsync(probe, MoShiroMethods.ProbeLinkRetry, new ProbeLinkRetryParams { AgentId = agent }, MoShiroJsonContext.Default.ProbeLinkRetryParams, ct); + public ValueTask NotifyLinkRetryAsync(NodeId dialer, NodeId listener, CancellationToken ct) => + Connections.NotifyAsync(dialer, MoShiroMethods.LinkRetry, new LinkRetryParams { ListenerId = listener }, MoShiroJsonContext.Default.LinkRetryParams, ct); public async ValueTask NotifyUpdateOfferAsync(NodeId node, string manifest, string signature, CancellationToken ct) => await Connections.NotifyAsync(node, MoShiroMethods.UpdateOffer, new UpdateOfferParams { Manifest = manifest, Signature = signature }, MoShiroJsonContext.Default.UpdateOfferParams, ct); diff --git a/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs index 54c28fb..c3bb59c 100644 --- a/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs @@ -10,7 +10,7 @@ namespace MoShiro.Backend.Gateway; public static class GatewayServiceCollectionExtensions { /// - /// 在 Fleet.Server 之上接入 MoShiro:期望状态(TaskSet)、RawChunk 收件箱、moshiro.* 方法、注册挂钩和节点事件。 + /// 在 Fleet.Server 之上接入 MoShiro:期望状态(TaskSet、DialTargets)、RawChunk 收件箱、moshiro.* 方法、注册挂钩和节点事件。 /// 需要事先注册 和 ,并在 AddMoShiroApplication 之后调用。 /// public static IServiceCollection AddMoShiroGateway(this IServiceCollection services) @@ -18,15 +18,11 @@ public static class GatewayServiceCollectionExtensions services.AddSingleton(); services.AddHostedService(); services.AddFleetEnrollment(); - services.AddFleetServer(options => - { - options.NodeIdPrefix = AgentIds.Prefix; - options.NodeIdPrefixes[JoinTokenService.ProbeRole] = ProbeId.Prefix; - }) + services.AddFleetServer(options => options.NodeIdPrefix = AgentIds.Prefix) .AddState() - .AddState() + .AddState() .AddInbox() - .WithMethods(MoShiroMethodTable.Table, node => NodeRoles.Of(node.Id.Value)) + .WithMethods(MoShiroMethodTable.Table) .MapMethods(MapMoShiroMethods); return services; } diff --git a/backend/src/MoShiro.Backend.Gateway/ProbeAgentsStateProvider.cs b/backend/src/MoShiro.Backend.Gateway/ProbeAgentsStateProvider.cs deleted file mode 100644 index d8f4697..0000000 --- a/backend/src/MoShiro.Backend.Gateway/ProbeAgentsStateProvider.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json; -using Fleet.Rpc; -using Fleet.Server; -using MoShiro.Backend.Application; -using MoShiro.Protocol; - -namespace MoShiro.Backend.Gateway; - -/// 期望状态:为每个 Probe 下发要连接的 Agent 列表(地址、端口、证书指纹)。Agent 集合变化时由 NotifyAllChanged 一起重算推送。 -public sealed class ProbeAgentsStateProvider : IStateProvider -{ - private readonly AgentService _agents; - - public ProbeAgentsStateProvider(AgentService agents) - { - _agents = agents; - } - - public string Type => ProbeAgentList.StateType; - - public async ValueTask GetAsync(NodeRecord node, CancellationToken ct) - { - if (!node.Id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal)) - { - return null; - } - - ProbeAgentList list = await _agents.ProbeAgentsAsync(node.Id.Value, ct); - return new StateDocument(list.Version, JsonSerializer.SerializeToElement(list, MoShiroJsonContext.Default.ProbeAgentList)); - } - - public ValueTask OnAppliedAsync(NodeRecord node, long version, StateSyncResult result, CancellationToken ct) => ValueTask.CompletedTask; -} diff --git a/backend/src/MoShiro.Backend.Gateway/TaskSetStateProvider.cs b/backend/src/MoShiro.Backend.Gateway/TaskSetStateProvider.cs index e257f5a..0f0e614 100644 --- a/backend/src/MoShiro.Backend.Gateway/TaskSetStateProvider.cs +++ b/backend/src/MoShiro.Backend.Gateway/TaskSetStateProvider.cs @@ -8,7 +8,7 @@ using MoShiro.Protocol; namespace MoShiro.Backend.Gateway; /// -/// 期望状态:为每个 Agent 下发完整的 快照(有效任务集 + Probe 公钥)。 +/// 期望状态:为每个 Agent 下发完整的 快照(有效任务集 + 允许拨入的拨号方公钥)。 /// 节点应用之后回传 applied_version 和 rejected[](unsupported、budget_exceeded、policy_denied),面板上可以看到。 /// public sealed class TaskSetStateProvider : IStateProvider @@ -26,11 +26,6 @@ public sealed class TaskSetStateProvider : IStateProvider public async ValueTask GetAsync(NodeRecord node, CancellationToken ct) { - if (node.Id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal)) - { - return null; // Probe 不运行任务集 - } - DesiredTaskSet? desired = await _planner.GetDesiredAsync(node.Id, ct); return desired is null ? null : new StateDocument(desired.Version, JsonSerializer.SerializeToElement(desired.Document, MoShiroJsonContext.Default.TaskSet)); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 4ab7a1e..3c72cb4 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -2,267 +2,36 @@ using Microsoft.Data.Sqlite; namespace MoShiro.Backend.Storage.Sqlite; -/// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 +/// +/// 表结构。版本记录在 PRAGMA user_version。当前只有一个版本:不是这个版本的数据库(包括更早的构建留下的)不会被迁移, +/// 启动时报错,删除 moshiro.db 和 raw/ 之后重新开始。以后的结构变更按版本号追加内置 SQL。 +/// internal static class Schema { - public const int CurrentVersion = 16; + public const int CurrentVersion = 1; public static void Migrate(SqliteConnection connection) { int version = connection.Scalar("PRAGMA user_version"); - if (version > CurrentVersion) + if (version == CurrentVersion) { - throw new InvalidOperationException($"database schema version {version} is newer than this build supports ({CurrentVersion})"); + return; } - if (version < 1) + if (version != 0) { - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec(V1); - connection.Exec("PRAGMA user_version = 1"); - transaction.Commit(); + throw new InvalidOperationException($"database schema version {version} is not supported by this build (expected {CurrentVersion}); delete moshiro.db and the raw/ directory to start over"); } - if (version < 2) - { - // 测试属于一条线路(Probe,Agent):旧测试没有 Probe,留空 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("ALTER TABLE tests ADD COLUMN probe_id TEXT NOT NULL DEFAULT ''"); - connection.Exec("CREATE INDEX ix_tests_line ON tests (agent_id, probe_id, requested_at)"); - connection.Exec("PRAGMA user_version = 2"); - transaction.Commit(); - } - - if (version < 3) - { - // 主机资源占用的历史(每个 Agent 每分钟一行) - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec(""" - CREATE TABLE host_samples ( - agent_id TEXT NOT NULL, - at INTEGER NOT NULL, - cpu REAL, - mem_used INTEGER, - mem_total INTEGER, - disk_used INTEGER, - disk_total INTEGER, - net_rx_bps INTEGER, - net_tx_bps INTEGER, - uptime INTEGER, - PRIMARY KEY (agent_id, at) - ) WITHOUT ROWID; - """); - connection.Exec("PRAGMA user_version = 3"); - transaction.Commit(); - } - - if (version < 4) - { - // 节点的展示信息:系统、架构、国家、运营商;geo_host 是查询用的地址,只在 Backend 内部用 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec(""" - CREATE TABLE node_meta ( - node_id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - os TEXT, - os_name TEXT, - arch TEXT, - country TEXT, - isp_key TEXT, - isp_name TEXT, - geo_host TEXT, - geo_at INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL DEFAULT 0 - ) WITHOUT ROWID - """); - connection.Exec("PRAGMA user_version = 4"); - transaction.Commit(); - } - - if (version < 5) - { - // 节点自报的构建信息:版本、tag、提交、构建时间 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("ALTER TABLE node_meta ADD COLUMN version TEXT"); - connection.Exec("ALTER TABLE node_meta ADD COLUMN tag TEXT"); - connection.Exec("ALTER TABLE node_meta ADD COLUMN commit_id TEXT"); - connection.Exec("ALTER TABLE node_meta ADD COLUMN built_at TEXT"); - connection.Exec("PRAGMA user_version = 5"); - transaction.Commit(); - } - - if (version < 6) - { - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_host TEXT NOT NULL DEFAULT ''"); - connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_supported INTEGER NOT NULL DEFAULT 0"); - connection.Exec("ALTER TABLE tests ADD COLUMN ip_version TEXT NOT NULL DEFAULT 'ipv4'"); - connection.Exec("CREATE INDEX ix_tests_path ON tests (agent_id, probe_id, ip_version, requested_at)"); - connection.Exec("PRAGMA user_version = 6"); - transaction.Commit(); - } - - if (version < 7) - { - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_diagnostics TEXT"); - connection.Exec("ALTER TABLE probes ADD COLUMN ipv6_diagnostics TEXT"); - connection.Exec("PRAGMA user_version = 7"); - transaction.Commit(); - } - - if (version < 8) - { - // Probe 用加入令牌注册:令牌带种类;Probe 有两把独立的密钥,对等面公钥单独存 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("ALTER TABLE join_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT ''"); - connection.Exec("ALTER TABLE probes ADD COLUMN peer_pubkey BLOB"); - connection.Exec("PRAGMA user_version = 8"); - transaction.Commit(); - } - - if (version < 9) - { - // 对外地址自动识别:手填的标记为 manual,自动识别不会覆盖;Agent 上报的地址存下来,撤销手填时马上能重新识别 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("ALTER TABLE agents ADD COLUMN public_host_manual INTEGER NOT NULL DEFAULT 0"); - connection.Exec("ALTER TABLE agents ADD COLUMN ipv6_host_manual INTEGER NOT NULL DEFAULT 0"); - connection.Exec("ALTER TABLE agents ADD COLUMN reported_addresses TEXT NOT NULL DEFAULT '[]'"); - connection.Exec("PRAGMA user_version = 9"); - transaction.Commit(); - } - - if (version < 10) - { - // 预握手敲门:Probe 需要 Agent 的证书公钥(不只是指纹)来派生配对密钥,由 Agent 在 hello 里上报 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("ALTER TABLE agents ADD COLUMN tls_pubkey BLOB"); - connection.Exec("PRAGMA user_version = 10"); - transaction.Commit(); - } - - if (version < 11) - { - // 权限从三个作用域改成细粒度的能力。破坏性变更,不做兼容:旧的 API 令牌全部作废,需要重新创建 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("DROP TABLE api_tokens"); - connection.Exec(""" - CREATE TABLE api_tokens ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - capabilities TEXT NOT NULL, - token_sha256 BLOB NOT NULL UNIQUE, - created_at INTEGER NOT NULL, - last_used_at INTEGER, - expires_at INTEGER, - revoked_at INTEGER - ) - """); - connection.Exec("PRAGMA user_version = 11"); - transaction.Commit(); - } - - if (version < 12) - { - // passkey 登录:每个用户可以注册若干 passkey(凭证公钥 SPKI、签名计数器);会话记录是怎么登录的,只有 passkey 登录的会话以后能提权 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec(""" - CREATE TABLE passkeys ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - public_key BLOB NOT NULL, - sign_count INTEGER NOT NULL DEFAULT 0, - transports TEXT NOT NULL DEFAULT '[]', - created_at INTEGER NOT NULL, - last_used_at INTEGER - ) - """); - connection.Exec("CREATE INDEX ix_passkeys_user ON passkeys (user_id)"); - connection.Exec("ALTER TABLE sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'password'"); - connection.Exec("PRAGMA user_version = 12"); - transaction.Commit(); - } - - if (version < 13) - { - // 归属:加入码是谁创建的,节点是用哪个加入码(也就是谁)加进来的;审计日志 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("ALTER TABLE join_tokens ADD COLUMN created_by TEXT"); - connection.Exec("ALTER TABLE agents ADD COLUMN added_by TEXT"); - connection.Exec("ALTER TABLE probes ADD COLUMN added_by TEXT"); - connection.Exec(""" - CREATE TABLE audit_log ( - id INTEGER PRIMARY KEY, - at INTEGER NOT NULL, - user_id TEXT, - actor TEXT NOT NULL, - method TEXT NOT NULL, - action TEXT NOT NULL, - target TEXT, - detail TEXT, - remote TEXT, - success INTEGER NOT NULL - ) - """); - connection.Exec("CREATE INDEX ix_audit_time ON audit_log (at)"); - connection.Exec( - """ - CREATE TABLE operator_sets ( - version INTEGER PRIMARY KEY, - statement TEXT NOT NULL, - credential_id TEXT NOT NULL, - authenticator_data TEXT NOT NULL, - client_data_json TEXT NOT NULL, - signature TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - """); - connection.Exec("PRAGMA user_version = 13"); - transaction.Commit(); - } - - if (version < 14) - { - // 节点的二进制绑定:第一次握手成功时绑定正在运行的二进制的 SHA-256 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec( - """ - CREATE TABLE node_bindings ( - node_id TEXT PRIMARY KEY, - sha256 TEXT NOT NULL, - seq INTEGER NOT NULL, - signed INTEGER NOT NULL, - bound_at INTEGER NOT NULL, - violation TEXT, - violation_at INTEGER - ) - """); - connection.Exec("PRAGMA user_version = 14"); - transaction.Commit(); - } - - if (version < 15) - { - // 运行时二进制绑定整体移除(节点自报的哈希不可信,来源证明在安装时):旧版本留下的表不再使用 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("DROP TABLE IF EXISTS node_bindings"); - connection.Exec("PRAGMA user_version = 15"); - transaction.Commit(); - } - - if (version < 16) - { - // 运营商可以手动指定(自动识别会错,例如 CN2 线路):手动的不会被后面的 IP 归属查询覆盖 - using SqliteTransaction transaction = connection.BeginTransaction(); - connection.Exec("ALTER TABLE node_meta ADD COLUMN isp_manual INTEGER NOT NULL DEFAULT 0"); - connection.Exec("PRAGMA user_version = 16"); - transaction.Commit(); - } + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec(V1); + connection.Exec("PRAGMA user_version = 1"); + transaction.Commit(); } /// /// 新增 Kind 或指标都不需要改表结构:指标是 metric_*.metrics 里的一个键,Kind 只是 raw_chunks.kind 的一个取值。 + /// 节点只有一张表(agents):监听的节点有地址和端口,不监听的节点只拨出。线路 (agent_id, series) 里的 agent_id 是监听方,series 里是拨号方。 /// private const string V1 = """ CREATE TABLE agents ( @@ -271,9 +40,17 @@ internal static class Schema status TEXT NOT NULL, pubkey BLOB NOT NULL, tls_spki BLOB NOT NULL, + tls_pubkey BLOB NOT NULL, + listens INTEGER NOT NULL DEFAULT 0, public_host TEXT NOT NULL DEFAULT '', - probe_tcp_port INTEGER NOT NULL DEFAULT 7443, - probe_udp_ports TEXT NOT NULL DEFAULT '[7443]', + tcp_port INTEGER NOT NULL DEFAULT 0, + quic_ports TEXT NOT NULL DEFAULT '[]', + ipv6_host TEXT NOT NULL DEFAULT '', + ipv6_supported INTEGER NOT NULL DEFAULT 0, + ipv6_diagnostics TEXT, + public_host_manual INTEGER NOT NULL DEFAULT 0, + ipv6_host_manual INTEGER NOT NULL DEFAULT 0, + reported_addresses TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]', public_visible INTEGER NOT NULL DEFAULT 1, kinds TEXT NOT NULL DEFAULT '[]', @@ -284,12 +61,67 @@ internal static class Schema rejected TEXT, task_hash TEXT, meta TEXT, + added_by TEXT, created_at INTEGER NOT NULL, last_seen_at INTEGER, row_version INTEGER NOT NULL DEFAULT 0 ); - -- 同名节点只允许一个已建立的(pending 的可以重名,等待管理员接管或改名) - CREATE UNIQUE INDEX ux_agents_name ON agents (name) WHERE status <> 'pending'; + + CREATE TABLE analysis_queue ( + agent_id TEXT NOT NULL, + series TEXT NOT NULL, + window_start INTEGER NOT NULL, + not_before INTEGER NOT NULL, + reason TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (agent_id, series, window_start) + ) WITHOUT ROWID; + + CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + capabilities TEXT NOT NULL, + token_sha256 BLOB NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER, + revoked_at INTEGER + ); + + CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + user_id TEXT, + actor TEXT NOT NULL, + method TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + detail TEXT, + remote TEXT, + success INTEGER NOT NULL + ); + + CREATE TABLE events ( + id INTEGER PRIMARY KEY, + agent_id TEXT, + kind TEXT NOT NULL, + at INTEGER NOT NULL, + detail TEXT + ); + + CREATE TABLE host_samples ( + agent_id TEXT NOT NULL, + at INTEGER NOT NULL, + cpu REAL, + mem_used INTEGER, + mem_total INTEGER, + disk_used INTEGER, + disk_total INTEGER, + net_rx_bps INTEGER, + net_tx_bps INTEGER, + uptime INTEGER, + PRIMARY KEY (agent_id, at) + ) WITHOUT ROWID; CREATE TABLE join_tokens ( id TEXT PRIMARY KEY, @@ -301,41 +133,105 @@ internal static class Schema uses INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL, revoked_at INTEGER, - created_at INTEGER NOT NULL - ); - - CREATE TABLE probes ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - pubkey BLOB NOT NULL, created_at INTEGER NOT NULL, - revoked_at INTEGER + kind TEXT NOT NULL DEFAULT '', + created_by TEXT ); - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash BLOB NOT NULL, - password_salt BLOB NOT NULL, - iterations INTEGER NOT NULL, + CREATE TABLE metric_10s (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; + + CREATE TABLE metric_1h (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; + + CREATE TABLE metric_1m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; + + CREATE TABLE metric_5m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; + + CREATE TABLE metric_versions ( + metric_id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + recompute_from INTEGER, + recompute_cursor INTEGER + ); + + CREATE TABLE node_meta ( + node_id TEXT PRIMARY KEY, + os TEXT, + os_name TEXT, + arch TEXT, + country TEXT, + isp_key TEXT, + isp_name TEXT, + isp_manual INTEGER NOT NULL DEFAULT 0, + geo_host TEXT, + geo_at INTEGER NOT NULL DEFAULT 0, + version TEXT, + tag TEXT, + commit_id TEXT, + built_at TEXT, + updated_at INTEGER NOT NULL DEFAULT 0 + ) WITHOUT ROWID; + + CREATE TABLE operator_sets ( + version INTEGER PRIMARY KEY, + statement TEXT NOT NULL, + credential_id TEXT NOT NULL, + authenticator_data TEXT NOT NULL, + client_data_json TEXT NOT NULL, + signature TEXT NOT NULL, created_at INTEGER NOT NULL ); + + CREATE TABLE passkeys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + last_used_at INTEGER + ); + + CREATE TABLE raw_chunks ( + chunk_id TEXT PRIMARY KEY, + segment_id INTEGER NOT NULL REFERENCES raw_segments(id), + offset INTEGER NOT NULL, + length INTEGER NOT NULL, + agent_id TEXT NOT NULL, + series TEXT NOT NULL, + kind TEXT NOT NULL, + task_id TEXT, + test_id TEXT, + phase INTEGER, + start_ts INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + partial INTEGER NOT NULL + ) WITHOUT ROWID; + + CREATE TABLE raw_segments ( + id INTEGER PRIMARY KEY, + day TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + bytes INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE rollup_dirty ( + level INTEGER NOT NULL, + agent_id TEXT NOT NULL, + series TEXT NOT NULL, + bucket_start INTEGER NOT NULL, + PRIMARY KEY (level, agent_id, series, bucket_start) + ) WITHOUT ROWID; + CREATE TABLE sessions ( id_sha256 BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_token BLOB NOT NULL, + auth_method TEXT NOT NULL DEFAULT 'password', created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); - CREATE TABLE api_tokens ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - scope TEXT NOT NULL, - token_sha256 BLOB NOT NULL UNIQUE, - created_at INTEGER NOT NULL, - last_used_at INTEGER, - revoked_at INTEGER - ); + CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE tasks ( @@ -352,9 +248,17 @@ internal static class Schema row_version INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE test_results ( + test_id TEXT PRIMARY KEY, + metrics BLOB NOT NULL, + series BLOB NOT NULL + ) WITHOUT ROWID; + CREATE TABLE tests ( id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, + dialer_id TEXT NOT NULL, + ip_version TEXT NOT NULL DEFAULT 'ipv4', plan TEXT NOT NULL, params TEXT NOT NULL, trigger TEXT NOT NULL, @@ -366,79 +270,42 @@ internal static class Schema started_at INTEGER, finished_at INTEGER ); - CREATE INDEX ix_tests_agent ON tests (agent_id, requested_at); - CREATE TABLE test_results ( - test_id TEXT PRIMARY KEY, - metrics BLOB NOT NULL, - series BLOB NOT NULL - ) WITHOUT ROWID; - CREATE TABLE raw_segments ( - id INTEGER PRIMARY KEY, - day TEXT NOT NULL, - path TEXT NOT NULL UNIQUE, - bytes INTEGER NOT NULL DEFAULT 0 + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash BLOB NOT NULL, + password_salt BLOB NOT NULL, + iterations INTEGER NOT NULL, + created_at INTEGER NOT NULL ); - CREATE TABLE raw_chunks ( - chunk_id TEXT PRIMARY KEY, - segment_id INTEGER NOT NULL REFERENCES raw_segments(id), - offset INTEGER NOT NULL, - length INTEGER NOT NULL, - agent_id TEXT NOT NULL, - series TEXT NOT NULL, - kind TEXT NOT NULL, - task_id TEXT, - test_id TEXT, - phase INTEGER, - start_ts INTEGER NOT NULL, - duration_ms INTEGER NOT NULL, - partial INTEGER NOT NULL - ) WITHOUT ROWID; - CREATE INDEX ix_raw_series ON raw_chunks (agent_id, series, start_ts); - CREATE INDEX ix_raw_test ON raw_chunks (test_id) WHERE test_id IS NOT NULL; - CREATE INDEX ix_raw_segment ON raw_chunks (segment_id); - CREATE TABLE metric_10s (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; - CREATE TABLE metric_1m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; - CREATE TABLE metric_5m (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; - CREATE TABLE metric_1h (agent_id TEXT NOT NULL, series TEXT NOT NULL, window_start INTEGER NOT NULL, partial INTEGER NOT NULL, metrics BLOB NOT NULL, PRIMARY KEY (agent_id, series, window_start)) WITHOUT ROWID; + CREATE INDEX ix_audit_time ON audit_log (at); + + CREATE INDEX ix_events_agent ON events (agent_id, at); + + CREATE INDEX ix_events_time ON events (at); + CREATE INDEX ix_metric_10s_time ON metric_10s (window_start); + CREATE INDEX ix_metric_1m_time ON metric_1m (window_start); + CREATE INDEX ix_metric_5m_time ON metric_5m (window_start); - CREATE TABLE metric_versions ( - metric_id TEXT PRIMARY KEY, - fingerprint TEXT NOT NULL, - recompute_from INTEGER, - recompute_cursor INTEGER - ); + CREATE INDEX ix_passkeys_user ON passkeys (user_id); - CREATE TABLE analysis_queue ( - agent_id TEXT NOT NULL, - series TEXT NOT NULL, - window_start INTEGER NOT NULL, - not_before INTEGER NOT NULL, - reason TEXT NOT NULL, - seq INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (agent_id, series, window_start) - ) WITHOUT ROWID; CREATE INDEX ix_queue_due ON analysis_queue (not_before); - CREATE TABLE rollup_dirty ( - level INTEGER NOT NULL, - agent_id TEXT NOT NULL, - series TEXT NOT NULL, - bucket_start INTEGER NOT NULL, - PRIMARY KEY (level, agent_id, series, bucket_start) - ) WITHOUT ROWID; - CREATE TABLE events ( - id INTEGER PRIMARY KEY, - agent_id TEXT, - kind TEXT NOT NULL, - at INTEGER NOT NULL, - detail TEXT - ); - CREATE INDEX ix_events_time ON events (at); - CREATE INDEX ix_events_agent ON events (agent_id, at); + CREATE INDEX ix_raw_segment ON raw_chunks (segment_id); + + CREATE INDEX ix_raw_series ON raw_chunks (agent_id, series, start_ts); + + CREATE INDEX ix_raw_test ON raw_chunks (test_id) WHERE test_id IS NOT NULL; + + CREATE INDEX ix_tests_agent ON tests (agent_id, requested_at); + + CREATE INDEX ix_tests_path ON tests (agent_id, dialer_id, ip_version, requested_at); + + CREATE UNIQUE INDEX ux_agents_name ON agents (name) WHERE status <> 'pending'; """; } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index 6f13a7f..f3963a5 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -13,8 +13,8 @@ namespace MoShiro.Backend.Storage.Sqlite; public sealed class SqliteAgentStore : IAgentStore, IFleetStore { private const string AgentColumns = - "id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " + - "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by"; + "id, name, status, pubkey, tls_spki, public_host, tcp_port, quic_ports, labels, public_visible, kinds, agent_version, local_limits, " + + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by, listens"; private readonly SqliteDatabase _db; @@ -35,10 +35,10 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore _db.ExecuteAsync(c => { c.Exec( - "UPDATE agents SET public_host = CASE WHEN public_host = '' THEN @host ELSE public_host END, probe_tcp_port = @tcp, probe_udp_ports = @udp, " + + "UPDATE agents SET public_host = CASE WHEN public_host = '' THEN @host ELSE public_host END, tcp_port = @tcp, quic_ports = @quic, listens = @listens, " + "kinds = CASE WHEN kinds = '[]' THEN @kinds ELSE kinds END, row_version = row_version + 1 WHERE id = @id", - ("@id", id.Value), ("@host", enrollment.PublicHost), ("@tcp", enrollment.ProbeTcpPort), - ("@udp", StorageJson.ToJson(enrollment.ProbeUdpPorts)), ("@kinds", StorageJson.ToJson(enrollment.Kinds))); + ("@id", id.Value), ("@host", enrollment.PublicHost), ("@tcp", enrollment.TcpPort), ("@listens", enrollment.TcpPort > 0 ? 1 : 0), + ("@quic", StorageJson.ToJson(enrollment.QuicPorts)), ("@kinds", StorageJson.ToJson(enrollment.Kinds))); }, ct); public ValueTask> PatchAsync(NodeId id, AgentPatch patch, long? expectedRowVersion, CancellationToken ct) => @@ -111,8 +111,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore // 旧记录换成新公钥,历史数据保留;待审批记录删除 c.Exec("DELETE FROM agents WHERE id = @id", ("@id", pendingId.Value)); c.Exec( - "UPDATE agents SET pubkey = @pubkey, tls_spki = @spki, status = 'active', task_hash = NULL, row_version = row_version + 1 WHERE id = @id", - ("@id", existingId), ("@pubkey", pending.PublicKey), ("@spki", pending.TlsSpki)); + "UPDATE agents SET pubkey = @pubkey, tls_spki = @spki, tls_pubkey = @peer, status = 'active', task_hash = NULL, row_version = row_version + 1 WHERE id = @id", + ("@id", existingId), ("@pubkey", pending.PublicKey), ("@spki", pending.TlsSpki), ("@peer", pending.TlsPublicKey)); return Result.Ok(GetInternal(c, new NodeId(existingId))!); }, ct); @@ -122,13 +122,15 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore public ValueTask ApplyHelloAsync(NodeId id, AgentHello hello, CancellationToken ct) => _db.ExecuteAsync(c => { + ListenInfo? listen = hello.Listen; c.Exec( - "UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), tls_spki = COALESCE(@spki, tls_spki), tls_pubkey = COALESCE(@pub, tls_pubkey), " + - "probe_tcp_port = COALESCE(@tcp, probe_tcp_port), probe_udp_ports = COALESCE(@udp, probe_udp_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, " + + "UPDATE agents SET kinds = @kinds, agent_version = @version, local_limits = COALESCE(@limits, local_limits), listens = @listens, " + + "tcp_port = COALESCE(@tcp, tcp_port), quic_ports = COALESCE(@quic, quic_ports), ipv6_supported = @v6, ipv6_diagnostics = @diag, " + "reported_addresses = COALESCE(@addr, reported_addresses), row_version = row_version + 1 WHERE id = @id", ("@id", id.Value), ("@kinds", StorageJson.ToJson(hello.Kinds)), ("@version", hello.AgentVersion), ("@limits", hello.LocalLimitsJson), - ("@spki", hello.TlsSpki), ("@pub", hello.TlsPublicKey), ("@tcp", hello.ProbeTcpPort), ("@udp", hello.ProbeUdpPorts is null ? null : StorageJson.ToJson(hello.ProbeUdpPorts)), ("@v6", hello.Ipv6Supported ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson), - ("@addr", hello.Addresses is null ? null : StorageJson.ToJson(hello.Addresses))); + ("@listens", listen is null ? 0 : 1), ("@tcp", listen?.TcpPort), ("@quic", listen is null ? null : StorageJson.ToJson(listen.QuicPorts)), + ("@v6", listen?.Ipv6Supported == true ? 1 : 0), ("@diag", hello.Ipv6DiagnosticsJson), + ("@addr", listen?.Addresses is null ? null : StorageJson.ToJson(listen.Addresses))); }, ct); public ValueTask ApplyAutoAddressesAsync(NodeId id, string? publicHost, string? ipv6Host, CancellationToken ct) => @@ -141,6 +143,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore ("@id", id.Value), ("@host", publicHost), ("@v6", ipv6Host)); }, ct); + public ValueTask SetIpv6DiagnosticsAsync(NodeId id, string diagnosticsJson, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("UPDATE agents SET ipv6_diagnostics = @diag WHERE id = @id", ("@diag", diagnosticsJson), ("@id", id.Value)), ct); + public ValueTask SetTaskVersionAsync(NodeId id, long version, string taskHash, CancellationToken ct) => _db.ExecuteAsync(c => c.Exec("UPDATE agents SET task_version = @v, task_hash = @h WHERE id = @id", ("@id", id.Value), ("@v", version), ("@h", taskHash)), ct); @@ -173,53 +178,20 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore public ValueTask> CreateNodeAsync(NewNode node, CancellationToken ct) => _db.WriteAsync(c => { - if (node.Kind == JoinTokenService.ProbeRole) - { - return CreateProbe(c, node); - } - if (node.Status != NodeStatus.Pending && NameTaken(c, node.Name, node.Id)) { return Result.Fail(FleetErrors.NameConflict, "a node with this name already exists"); } c.Exec( - "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)", - ("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), + "INSERT INTO agents (id, name, status, pubkey, tls_spki, tls_pubkey, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @peer, @labels, @meta, @version, @now, @by)", + ("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), ("@peer", node.TlsPublicKey), ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy)); return Result.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson)); }, ct); - /// Probe 用加入令牌注册:直接进 probes 表,有令牌就算批准(Probe 没有 pending 状态);两把公钥都是它自己生成的。 - private static Result CreateProbe(SqliteConnection c, NewNode node) - { - if (node.TlsPublicKey is not { Length: > 0 } peerKey) - { - return Result.Fail(FleetErrors.InvalidParams, "a probe needs its peer key"); - } - - if (c.Scalar("SELECT COUNT(*) FROM probes WHERE name = @name COLLATE NOCASE", ("@name", node.Name)) > 0) - { - return Result.Fail(FleetErrors.NameConflict, "a probe with this name already exists"); - } - - c.Exec( - "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)", - ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy)); - return Result.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null)); - } - - /// Agent 在 agents 表里;Probe 的身份在 probes 表里(吊销之后按「禁用」处理,连不上)。 - async ValueTask IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) => - await GetAsync(id, ct) is { } agent - ? ToNode(agent, null) - : await _db.ReadAsync( - c => c.QueryOne( - "SELECT id, name, pubkey, created_at, revoked_at FROM probes WHERE id = @id", - r => new NodeRecord( - new NodeId(r.GetString(0)), r.GetString(1), r.IsDBNull(4) ? NodeStatus.Active : NodeStatus.Disabled, r.Bytes(2), [], [], null, null, r.GetInt64(3), null), - ("@id", id.Value)), - ct); + ValueTask IFleetStore.GetNodeAsync(NodeId id, CancellationToken ct) => + _db.ReadAsync(c => GetInternal(c, id) is { } agent ? ToNode(agent, null) : null, ct); public ValueTask FindEstablishedNodeByNameAsync(string name, CancellationToken ct) => _db.ReadAsync( @@ -268,7 +240,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.GetInt64(23) != 0, StorageJson.Strings(r.GetString(24)), r.NullableBytes(25), - r.NullableString(26)); + r.NullableString(26), + r.GetInt64(27) != 0); private static NodeRecord ToNode(AgentRecord a, string? metaJson) => new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index 1244c2d..67ff007 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -47,44 +47,6 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore _db.WriteAsync(c => c.Exec("DELETE FROM join_tokens WHERE id = @id", ("@id", id)) == 1, ct); } -public sealed class SqliteProbeStore : IProbeStore -{ - private readonly SqliteDatabase _db; - - public SqliteProbeStore(SqliteDatabase db) - { - _db = db; - } - - public ValueTask> ListAsync(CancellationToken ct) => - _db.ReadAsync>( - c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7))), - ct); - - public ValueTask GetAsync(string id, CancellationToken ct) => - _db.ReadAsync( - c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7)), ("@id", id)), - ct); - - public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) => - _db.ExecuteAsync(c => c.Exec("UPDATE probes SET ipv6_diagnostics = @diag WHERE id = @id", ("@diag", diagnosticsJson), ("@id", id)), ct); - - public ValueTask RevokeAsync(string id, long nowMs, CancellationToken ct) => - _db.WriteAsync(c => c.Exec("UPDATE probes SET revoked_at = @now WHERE id = @id AND revoked_at IS NULL", ("@id", id), ("@now", nowMs)) == 1, ct); - - public ValueTask DeleteAsync(string id, CancellationToken ct) => - _db.WriteAsync(c => c.Exec("DELETE FROM probes WHERE id = @id", ("@id", id)) == 1, ct); - - public ValueTask SaveAsync(ProbeRecord probe, CancellationToken ct) => - _db.ExecuteAsync(c => - { - c.Exec( - "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, revoked_at) VALUES (@id, @name, @pubkey, @peer, @at, @revoked) " + - "ON CONFLICT(id) DO UPDATE SET name = excluded.name, pubkey = excluded.pubkey, peer_pubkey = excluded.peer_pubkey, created_at = excluded.created_at, revoked_at = excluded.revoked_at", - ("@id", probe.Id), ("@name", probe.Name), ("@pubkey", probe.PublicKey), ("@peer", probe.PeerPublicKey), ("@at", probe.CreatedAtMs), ("@revoked", probe.RevokedAtMs)); - }, ct); -} - public sealed class SqliteSettingsStore : ISettingsStore { private readonly SqliteDatabase _db; diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs index 1850c6e..7461dbe 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteNodeMetaStore.cs @@ -14,18 +14,18 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( - c => c.Query("SELECT node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual FROM node_meta", Read), + c => c.Query("SELECT node_id, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual FROM node_meta", Read), ct); public ValueTask SaveAsync(NodeMeta meta, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( - "INSERT INTO node_meta (node_id, kind, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual) " + - "VALUES (@id, @kind, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt, @im) ON CONFLICT(node_id) DO UPDATE SET " + - "kind = excluded.kind, os = excluded.os, os_name = excluded.os_name, arch = excluded.arch, country = excluded.country, " + + "INSERT INTO node_meta (node_id, os, os_name, arch, country, isp_key, isp_name, geo_host, geo_at, updated_at, version, tag, commit_id, built_at, isp_manual) " + + "VALUES (@id, @os, @osn, @arch, @cc, @ik, @in, @gh, @ga, @ua, @ver, @tag, @cm, @bt, @im) ON CONFLICT(node_id) DO UPDATE SET " + + "os = excluded.os, os_name = excluded.os_name, arch = excluded.arch, country = excluded.country, " + "isp_key = excluded.isp_key, isp_name = excluded.isp_name, geo_host = excluded.geo_host, geo_at = excluded.geo_at, updated_at = excluded.updated_at, " + "version = excluded.version, tag = excluded.tag, commit_id = excluded.commit_id, built_at = excluded.built_at, isp_manual = excluded.isp_manual", - ("@id", meta.Info.NodeId), ("@kind", meta.Info.Kind), ("@os", meta.Info.Os), ("@osn", meta.Info.OsName), ("@arch", meta.Info.Arch), ("@cc", meta.Info.Country), + ("@id", meta.Info.NodeId), ("@os", meta.Info.Os), ("@osn", meta.Info.OsName), ("@arch", meta.Info.Arch), ("@cc", meta.Info.Country), ("@ik", meta.Info.IspKey), ("@in", meta.Info.IspName), ("@gh", meta.GeoHost), ("@ga", meta.GeoAtMs), ("@ua", meta.UpdatedAtMs), ("@ver", meta.Info.Version), ("@tag", meta.Info.Tag), ("@cm", meta.Info.Commit), ("@bt", meta.Info.BuiltAt), ("@im", meta.Info.IspManual ? 1 : 0)), ct); @@ -36,6 +36,6 @@ public sealed class SqliteNodeMetaStore : INodeMetaStore private static NodeMeta Read(SqliteDataReader r) { static string? S(SqliteDataReader reader, int i) => reader.IsDBNull(i) ? null : reader.GetString(i); - return new NodeMeta(new NodeInfo(r.GetString(0), r.GetString(1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 7), S(r, 11), S(r, 12), S(r, 13), S(r, 14), r.GetInt64(15) != 0), S(r, 8), r.GetInt64(9), r.GetInt64(10)); + return new NodeMeta(new NodeInfo(r.GetString(0), S(r, 1), S(r, 2), S(r, 3), S(r, 4), S(r, 5), S(r, 6), S(r, 10), S(r, 11), S(r, 12), S(r, 13), r.GetInt64(14) != 0), S(r, 7), r.GetInt64(8), r.GetInt64(9)); } } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteTestStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteTestStore.cs index eba95d9..348856a 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteTestStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteTestStore.cs @@ -7,7 +7,7 @@ namespace MoShiro.Backend.Storage.Sqlite; public sealed class SqliteTestStore : ITestStore { - private const string Columns = "id, agent_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, started_at, finished_at, probe_id, ip_version"; + private const string Columns = "id, agent_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, started_at, finished_at, dialer_id, ip_version"; private readonly SqliteDatabase _db; @@ -19,22 +19,22 @@ public sealed class SqliteTestStore : ITestStore public ValueTask CreateAsync(TestRecord test, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( - "INSERT INTO tests (id, agent_id, probe_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, ip_version) VALUES (@id, @agent, @probe, @plan, @params, @trigger, @status, @err, @phase, @count, @at, @ip)", - ("@id", test.Id.Value), ("@agent", test.AgentId.Value), ("@probe", test.ProbeId.Value), ("@plan", test.Plan), ("@params", test.ParamsJson), ("@trigger", test.Trigger), + "INSERT INTO tests (id, agent_id, dialer_id, plan, params, trigger, status, error_code, phase, phase_count, requested_at, ip_version) VALUES (@id, @agent, @dialer, @plan, @params, @trigger, @status, @err, @phase, @count, @at, @ip)", + ("@id", test.Id.Value), ("@agent", test.AgentId.Value), ("@dialer", test.DialerId.Value), ("@plan", test.Plan), ("@params", test.ParamsJson), ("@trigger", test.Trigger), ("@status", test.Status), ("@err", test.ErrorCode), ("@phase", test.Phase), ("@count", test.PhaseCount), ("@at", test.RequestedAtMs), ("@ip", test.IpVersion)), ct); public ValueTask GetAsync(TestId id, CancellationToken ct) => _db.ReadAsync(c => c.QueryOne($"SELECT {Columns} FROM tests WHERE id = @id", Read, ("@id", id.Value)), ct); - public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) => - ListAsync(agentId, probeId, limit, null, ct); + public ValueTask> ListAsync(NodeId? agentId, NodeId? dialerId, int limit, CancellationToken ct) => + ListAsync(agentId, dialerId, limit, null, ct); - public ValueTask> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, string? ipVersion, CancellationToken ct) => + public ValueTask> ListAsync(NodeId? agentId, NodeId? dialerId, int limit, string? ipVersion, CancellationToken ct) => _db.ReadAsync>( c => c.Query( - $"SELECT {Columns} FROM tests WHERE (@agent IS NULL OR agent_id = @agent) AND (@probe IS NULL OR probe_id = @probe) AND (@ip IS NULL OR ip_version = @ip) ORDER BY requested_at DESC, id DESC LIMIT @limit", - Read, ("@agent", agentId?.Value), ("@probe", probeId?.Value), ("@limit", limit), ("@ip", ipVersion)), + $"SELECT {Columns} FROM tests WHERE (@agent IS NULL OR agent_id = @agent) AND (@dialer IS NULL OR dialer_id = @dialer) AND (@ip IS NULL OR ip_version = @ip) ORDER BY requested_at DESC, id DESC LIMIT @limit", + Read, ("@agent", agentId?.Value), ("@dialer", dialerId?.Value), ("@limit", limit), ("@ip", ipVersion)), ct); public ValueTask UpdateStatusAsync(TestId id, string status, int? phase, string? errorCode, long nowMs, CancellationToken ct) => @@ -49,8 +49,8 @@ public sealed class SqliteTestStore : ITestStore public ValueTask SetPhaseCountAsync(TestId id, int phaseCount, CancellationToken ct) => _db.ExecuteAsync(c => c.Exec("UPDATE tests SET phase_count = @n WHERE id = @id", ("@id", id.Value), ("@n", phaseCount)), ct); - public ValueTask HasActiveAsync(NodeId agentId, ProbeId probeId, CancellationToken ct) => - _db.ReadAsync(c => c.Scalar("SELECT COUNT(*) FROM tests WHERE agent_id = @a AND probe_id = @p AND status IN ('queued', 'running')", ("@a", agentId.Value), ("@p", probeId.Value)) > 0, ct); + public ValueTask HasActiveAsync(NodeId agentId, NodeId dialerId, CancellationToken ct) => + _db.ReadAsync(c => c.Scalar("SELECT COUNT(*) FROM tests WHERE agent_id = @a AND dialer_id = @p AND status IN ('queued', 'running')", ("@a", agentId.Value), ("@p", dialerId.Value)) > 0, ct); public ValueTask SaveResultAsync(TestResultRecord result, CancellationToken ct) => _db.ExecuteAsync( @@ -81,7 +81,7 @@ public sealed class SqliteTestStore : ITestStore private static TestRecord Read(SqliteDataReader r) => new( new TestId(r.GetString(0)), new NodeId(r.GetString(1)), - new ProbeId(r.GetString(12)), + new NodeId(r.GetString(12)), r.GetString(2), r.GetString(3), r.GetString(4), diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index 6c3ad76..39e7915 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -14,7 +14,6 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(p => p.GetRequiredService()); services.AddSingleton(p => p.GetRequiredService()); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend/BackendHost.cs b/backend/src/MoShiro.Backend/BackendHost.cs index bb6e647..278c614 100644 --- a/backend/src/MoShiro.Backend/BackendHost.cs +++ b/backend/src/MoShiro.Backend/BackendHost.cs @@ -38,7 +38,7 @@ public static class BackendHost IServiceCollection services = builder.Services; services.AddSingleton(time ?? TimeProvider.System); services.AddMoShiroSqlite(options.DataDirectory); - // 国家和运营商:Backend 用节点的来源地址查一次(Probe、Agent 不访问任何外部服务);地址留空就不查 + // 国家和运营商:Backend 用节点的来源地址查一次(拨号方、Agent 不访问任何外部服务);地址留空就不查 if (string.IsNullOrWhiteSpace(options.GeoLookupUrl)) { services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend/Program.cs b/backend/src/MoShiro.Backend/Program.cs index 3cb6164..f4ea50b 100644 --- a/backend/src/MoShiro.Backend/Program.cs +++ b/backend/src/MoShiro.Backend/Program.cs @@ -1,7 +1,7 @@ using MoShiro.Backend; WebApplication app = BackendHost.Build(args); -// 启动时打一行构建信息:版本、tag、提交、构建时间(和 Agent、Probe 启动时打的是同一组) +// 启动时打一行构建信息:版本、tag、提交、构建时间(和 Agent 启动时打的是同一组) if (app.Logger.IsEnabled(LogLevel.Information)) { app.Logger.LogInformation("moshiro backend starting: version {BuildInfo}", MoShiro.Protocol.BuildInfo.Summary); diff --git a/backend/tests/MoShiro.Backend.Analysis.Tests/ModelAndTestEvaluatorTests.cs b/backend/tests/MoShiro.Backend.Analysis.Tests/ModelAndTestEvaluatorTests.cs index 4f90ba2..42d667a 100644 --- a/backend/tests/MoShiro.Backend.Analysis.Tests/ModelAndTestEvaluatorTests.cs +++ b/backend/tests/MoShiro.Backend.Analysis.Tests/ModelAndTestEvaluatorTests.cs @@ -111,7 +111,7 @@ public sealed class ModelAndTestEvaluatorTests [Fact] public void TestEvaluator_Hy2Ladder_APhaseSplitIntoSeveralChunksIsOneStep() { - // 真实的 Probe 把一个阶段切成几块(一块最多 50000 条记录):阶梯里的一步是一个阶段,不是一个块 + // 真实的拨号侧 Agent 把一个阶段切成几块(一块最多 50000 条记录):阶梯里的一步是一个阶段,不是一个块 RawChunk[] chunks = [ ChunkFactory.Train(s_agent, T0, 10, 0), diff --git a/backend/tests/MoShiro.Backend.Analysis.Tests/OpTests.cs b/backend/tests/MoShiro.Backend.Analysis.Tests/OpTests.cs index 30fa8ab..949c0fb 100644 --- a/backend/tests/MoShiro.Backend.Analysis.Tests/OpTests.cs +++ b/backend/tests/MoShiro.Backend.Analysis.Tests/OpTests.cs @@ -187,7 +187,7 @@ public sealed class OpTests [Fact] public void Retrans_UsesKernelCounterDeltas() { - long baseUs = ChunkFactory.ProbeUs(0); + long baseUs = ChunkFactory.DialerUs(0); RawStream info = ChunkFactory.TcpInfo( [ (baseUs, 0, 40_000u, 0u, 100u), @@ -203,7 +203,7 @@ public sealed class OpTests [Fact] public void Retrans_CounterResetNeverGoesNegative() { - long baseUs = ChunkFactory.ProbeUs(0); + long baseUs = ChunkFactory.DialerUs(0); RawStream info = ChunkFactory.TcpInfo([(baseUs, 0, 1u, 10u, 100u), (baseUs + 1_000_000, 0, 1u, 0u, 50u), (baseUs + 2_000_000, 0, 1u, 1u, 150u)]); RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100, _ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]); var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"]; @@ -214,7 +214,7 @@ public sealed class OpTests [Fact] public void Retrans_InterleavedConnections_DoNotCreateFalseRetransmissions() { - long ts = ChunkFactory.ProbeUs(0); + long ts = ChunkFactory.DialerUs(0); var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!); builder.Add(ts, 0, 40_000, 0, 10, 10, 1000, 0, 1); builder.Add(ts + 1_000_000, 0, 40_000, 0, 10, 20, 1100, 0, 2); @@ -230,7 +230,7 @@ public sealed class OpTests [Fact] public void Retrans_OutOfOrderSamples_DoNotMoveTheBaselineBackwards() { - long ts = ChunkFactory.ProbeUs(0); + long ts = ChunkFactory.DialerUs(0); RawStream info = ChunkFactory.TcpInfo([ (ts, 0, 40_000u, 0u, 100u), (ts + 2_000_000, 0, 40_000u, 2u, 300u), @@ -247,7 +247,7 @@ public sealed class OpTests [Fact] public void Retrans_EqualConnectionIdsOnDifferentSides_AreIndependent() { - long ts = ChunkFactory.ProbeUs(0); + long ts = ChunkFactory.DialerUs(0); var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!); builder.Add(ts, 0, 40_000, 0, 10, 10, 1000, 0, 1); builder.Add(ts, 1, 40_000, 0, 10, 100, 2000, 0, 1); diff --git a/backend/tests/MoShiro.Backend.Tests/AddressTests.cs b/backend/tests/MoShiro.Backend.Tests/AddressTests.cs index b7c6730..0664777 100644 --- a/backend/tests/MoShiro.Backend.Tests/AddressTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AddressTests.cs @@ -1,3 +1,4 @@ +using Fleet.Core; using System.Net; using Microsoft.Extensions.DependencyInjection; using MoShiro.Backend.Application; @@ -91,12 +92,13 @@ public sealed class AddressTests : IClassFixture } [Fact] - public async Task ProbeAgentList_CarriesTheCertificatePublicKeyTheAgentReported_ForPinningAndKnocking() + public async Task DialTargets_CarryTheCertificatePublicKeyTheNodeEnrolledWith_ForPinningAndKnocking() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); AgentService service = _backend.Services.GetRequiredService(); - ProbeAgentList list = await service.ProbeAgentsAsync(_backend.DefaultProbeId, default); - AgentEndpoint endpoint = Assert.Single(list.Agents, a => a.AgentId == agent.NodeId); + AgentRecord dialer = (await _backend.Services.GetRequiredService().GetAsync(new NodeId(_backend.DefaultDialerId), default))!; + DialTargets list = await service.DialTargetsAsync(dialer, default); + DialTarget endpoint = Assert.Single(list.Targets, t => t.NodeId == agent.NodeId); Assert.Equal(Convert.ToBase64String(agent.TlsPublicKey), endpoint.PublicKey); } } diff --git a/backend/tests/MoShiro.Backend.Tests/AgentDataPlaneTests.cs b/backend/tests/MoShiro.Backend.Tests/AgentDataPlaneTests.cs index 540c6c7..c2b8959 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentDataPlaneTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentDataPlaneTests.cs @@ -17,21 +17,21 @@ public sealed class AgentDataPlaneTests : IClassFixture _backend = backend; } - private async Task<(AgentHarness Agent, ProbeSimulator Probe, LineTask Udp)> LineAsync() + private async Task<(AgentHarness Agent, DialerSimulator Dialer, LineTask Udp)> LineAsync() { AgentHarness agent = await AgentHarness.StartAsync(_backend); await agent.OverrideLineTasksAsync(rateHz: 30); - ProbeSimulator probe = await agent.ConnectProbeAsync(); - LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0])); - return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo")); + DialerSimulator dialer = await agent.ConnectDialerAsync(); + LineTaskSet set = await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0])); + return (agent, dialer, set.Tasks.Single(t => t.Kind == "udp_echo")); } [Fact] public async Task TcpDataPlane_ClosesConnectionsWithoutAValidFirstFrame() { - (AgentHarness agent, ProbeSimulator probe, _) = await LineAsync(); + (AgentHarness agent, DialerSimulator dialer, _) = await LineAsync(); await using (agent) - await using (probe) + await using (dialer) { using var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, agent.App.TcpPorts[0]); @@ -45,21 +45,21 @@ public sealed class AgentDataPlaneTests : IClassFixture [Fact] public async Task DataPlane_DropsForgedReplayedAndGarbagePackets_WithoutAnswering() { - (AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync(); + (AgentHarness agent, DialerSimulator dialer, LineTask udp) = await LineAsync(); await using (agent) - await using (probe) + await using (dialer) { long before = agent.App.Counters.DroppedInvalid; - await probe.SendRawUdpAsync([1, 2, 3]); - await probe.SendRawUdpAsync(new byte[64]); - await probe.SendForgedEchoAsync(udp, 1); + await dialer.SendRawUdpAsync([1, 2, 3]); + await dialer.SendRawUdpAsync(new byte[64]); + await dialer.SendForgedEchoAsync(udp, 1); await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid - before >= 3), because: "garbage and forged packets are counted"); // 重放:同一个序号发两次,只有第一次被接受 - await probe.SendEchoAsync(udp, 500); + await dialer.SendEchoAsync(udp, 500); await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.PacketsOut >= 1)); long dropped = agent.App.Counters.DroppedInvalid; - await probe.SendEchoAsync(udp, 500); + await dialer.SendEchoAsync(udp, 500); await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.DroppedInvalid > dropped), because: "a replayed packet is dropped"); } } diff --git a/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs b/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs index 3f29da2..d489a8d 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentEndToEndTests.cs @@ -1,3 +1,4 @@ +using Fleet.Core; using System.Text.Json.Nodes; using MoShiro.Measurement; using MoShiro.Protocol; @@ -5,7 +6,7 @@ using MoShiro.TestSupport; namespace MoShiro.Backend.Tests; -/// 真实的后端 + 真实的 Agent(进程内)+ 假 Probe:覆盖 Agent 与 Backend 的链路、Probe 与 Agent 的点对点会话和数据面。 +/// 真实的后端 + 真实的 Agent(进程内)+ 拨号侧模拟器:覆盖 Agent 与 Backend 的链路、Dialer 与 Agent 的点对点会话和数据面。 // 使用真实 Agent/QUIC/TCP 会话的端到端验证放在慢测集合,避免和大量 API/单元测试争抢本机套接字。 [Trait("Category", "Slow")] public sealed class AgentEndToEndTests : IClassFixture @@ -17,30 +18,17 @@ public sealed class AgentEndToEndTests : IClassFixture _backend = backend; } - private async Task<(AgentHarness Agent, ProbeSimulator Probe, LineTask Udp)> LineAsync() + private async Task<(AgentHarness Agent, DialerSimulator Dialer, LineTask Udp)> LineAsync() { AgentHarness agent = await AgentHarness.StartAsync(_backend); await agent.OverrideLineTasksAsync(rateHz: 30); - ProbeSimulator probe = await agent.ConnectProbeAsync(); - LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0])); - return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo")); + DialerSimulator dialer = await agent.ConnectDialerAsync(); + LineTaskSet set = await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0])); + return (agent, dialer, set.Tasks.Single(t => t.Kind == "udp_echo")); } private async Task AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id); - [Fact] - public async Task Agent_ConnectsAndReportsItsRealPorts_AndTheProbeSessionIsVisibleToTheBackend() - { - await using AgentHarness agent = await AgentHarness.StartAsync(_backend); - JsonNode view = await AgentViewAsync(agent); - Assert.Equal("active", view["status"]!.GetValue()); - Assert.False(view["probe_connected"]!.GetValue()); - - await using ProbeSimulator probe = await agent.ConnectProbeAsync(); - await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); - await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["runtime_status"]!.GetValue() == "running"); - } - [Fact] public async Task Agent_OrderlyShutdownIsReportedAsStoppingRatherThanOffline() { @@ -57,11 +45,11 @@ public sealed class AgentEndToEndTests : IClassFixture [Fact] public async Task UdpEcho_EndToEnd_FragmentsAreMergedWithAgentTimestamps_AndStoredByTheBackend() { - (AgentHarness agent, ProbeSimulator probe, LineTask udp) = await LineAsync(); + (AgentHarness agent, DialerSimulator dialer, LineTask udp) = await LineAsync(); await using (agent) - await using (probe) + await using (dialer) { - (int sent, int received) = await probe.RunUdpEchoAsync(udp, 40, TimeSpan.FromMilliseconds(40)); + (int sent, int received) = await dialer.RunUdpEchoAsync(udp, 40, TimeSpan.FromMilliseconds(40)); Assert.Equal(40, sent); Assert.Equal(40, received); @@ -84,12 +72,12 @@ public sealed class AgentEndToEndTests : IClassFixture [InlineData(true)] public async Task TcpEcho_EndToEnd_AuthenticatedFirstFrame_PlainOrTls(bool tls) { - (AgentHarness agent, ProbeSimulator probe, _) = await LineAsync(); + (AgentHarness agent, DialerSimulator dialer, _) = await LineAsync(); await using (agent) - await using (probe) + await using (dialer) { - LineTask tcp = (await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "tcp_echo" && t.Port == agent.App.TcpPorts[0]))).Tasks.Single(t => t.Kind == "tcp_echo"); - (int sent, int received) = await probe.RunTcpEchoAsync(tcp, agent.App.TcpPorts[0], 15, TimeSpan.FromMilliseconds(60), tls); + LineTask tcp = (await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "tcp_echo" && t.Port == agent.App.TcpPorts[0]))).Tasks.Single(t => t.Kind == "tcp_echo"); + (int sent, int received) = await dialer.RunTcpEchoAsync(tcp, agent.App.TcpPorts[0], 15, TimeSpan.FromMilliseconds(60), tls); Assert.Equal(15, sent); Assert.Equal(15, received); await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Any(c => c!["kind"]!.GetValue() == "tcp_echo")); @@ -97,9 +85,9 @@ public sealed class AgentEndToEndTests : IClassFixture } [Fact] - public async Task ARestartedProbe_NeverInheritsTheAgentRecordsOfItsPreviousSession() + public async Task ARestartedDialer_NeverInheritsTheAgentRecordsOfItsPreviousSession() { - (AgentHarness agent, ProbeSimulator first, LineTask udp) = await LineAsync(); + (AgentHarness agent, DialerSimulator first, LineTask udp) = await LineAsync(); await using (agent) { await first.RunUdpEchoAsync(udp, 20, TimeSpan.FromMilliseconds(40)); @@ -107,8 +95,8 @@ public sealed class AgentEndToEndTests : IClassFixture Assert.True(records.Contains(5), "the agent recorded t2/t3 for the first session's packets"); await first.DisposeAsync(); - // The probe process restarts: a new session with its own streams; sequence numbers start from 0 again - await using ProbeSimulator second = first.Restarted(); + // The dialer process restarts: a new session with its own streams; sequence numbers start from 0 again + await using DialerSimulator second = first.Restarted(); await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId); LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0])); LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo"); @@ -125,26 +113,26 @@ public sealed class AgentEndToEndTests : IClassFixture } [Fact] - public async Task SeveralProbes_AreReportedTogether_CanBeDroppedOneByOne_AndARevokedOneIsKickedOut() + public async Task SeveralDialers_AreReportedTogether_CanBeDroppedOneByOne_AndADisabledOneIsKickedOut() { await using AgentHarness agent = await AgentHarness.StartAsync(_backend); await agent.OverrideLineTasksAsync(rateHz: 30); - await using ProbeSimulator a = await agent.ConnectProbeAsync(); - await using ProbeSimulator b = await agent.ConnectProbeAsync(); + await using DialerSimulator a = await agent.ConnectDialerAsync(); + await using DialerSimulator b = await agent.ConnectDialerAsync(); - // The agent reports both sessions with version and address; the probe view shows the agent it is connected to + // The agent reports both sessions with version and address; the dialer view shows the agent it is connected to JsonNode view = null!; await BackendFixture.EventuallyAsync( async () => { view = await AgentViewAsync(agent); - return view["probes"]!.AsArray().Count == 2; + return view["dialers"]!.AsArray().Count == 2; }, - because: "the agent reports every connected probe"); - Assert.All(view["probes"]!.AsArray(), p => Assert.Equal("0.0.0-sim", p!["version"]!.GetValue())); - Assert.Equal("127.0.0.1", view["probes"]![0]!["remote"]!.GetValue()); - JsonNode probeView = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes/" + a.ProbeId); - Assert.Equal(agent.Id, probeView["connections"]![0]!["agent_id"]!.GetValue()); + because: "the agent reports every connected dialer"); + Assert.All(view["dialers"]!.AsArray(), p => Assert.Equal("0.0.0-sim", p!["version"]!.GetValue())); + Assert.Equal("127.0.0.1", view["dialers"]![0]!["remote"]!.GetValue()); + JsonNode dialerView = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + a.DialerId); + Assert.Equal(agent.Id, dialerView["outgoing"]![0]!["listener_id"]!.GetValue()); // Both get their own task set (own streams) LineTaskSet setA = await a.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo")); @@ -152,56 +140,44 @@ public sealed class AgentEndToEndTests : IClassFixture Assert.NotEqual(setA.Tasks.Single(t => t.Kind == "udp_echo").StreamId, setB.Tasks.Single(t => t.Kind == "udp_echo").StreamId); // Drop one: the other stays - using HttpResponseMessage dropped = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/probes/{a.ProbeId}/disconnect", null); + using HttpResponseMessage dropped = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/dialers/{a.DialerId}/disconnect", null); Assert.Equal(System.Net.HttpStatusCode.NoContent, dropped.StatusCode); - await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probes"]!.AsArray().Count == 1, because: "only the dropped probe leaves"); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["dialers"]!.AsArray().Count == 1, because: "only the dropped dialer leaves"); Assert.True(b.IsConnected); - using HttpResponseMessage again = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/probes/{a.ProbeId}/disconnect", null); + using HttpResponseMessage again = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.Id}/dialers/{a.DialerId}/disconnect", null); Assert.Equal(System.Net.HttpStatusCode.Conflict, again.StatusCode); - // Revoke the remaining one: the agent closes its session - using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{b.ProbeId}/revoke", null); + // Disable the remaining Agent: the listener closes its session + using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/agents/{b.DialerId}/disable", null); revoked.EnsureSuccessStatusCode(); - await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probes"]!.AsArray().Count == 0, because: "a revoked probe is disconnected"); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["dialers"]!.AsArray().Count == 0, because: "a disabled Agent is disconnected"); } [Fact] - public async Task PeerHandshake_RejectsAProbeWhoseKeyTheBackendNeverIssued() + public async Task PeerHandshake_RejectsADialerWhoseKeyTheBackendNeverIssued() { await using AgentHarness agent = await AgentHarness.StartAsync(_backend); - await agent.CreateProbeAsync(); - await using var stranger = new ProbeSimulator("prb_stranger", Fleet.Identity.NodeIdentity.Generate()); + await agent.CreateDialerAsync(); + await using var stranger = new DialerSimulator("agt_stranger", Fleet.Identity.NodeIdentity.Generate()); await Assert.ThrowsAnyAsync(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId)); // 证书公钥不对(中间人)也不会连上 - await using ProbeSimulator probe = await agent.CreateProbeAsync(); - await Assert.ThrowsAnyAsync(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.QuicPorts[0], agent.NodeId)); + await using DialerSimulator dialer = await agent.CreateDialerAsync(); + await Assert.ThrowsAnyAsync(() => dialer.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.QuicPorts[0], agent.NodeId)); } [Fact] - public async Task ProbeDisconnect_IsReportedToTheBackend() - { - (AgentHarness agent, ProbeSimulator probe, _) = await LineAsync(); - await using (agent) - { - await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); - await probe.DisposeAsync(); - await BackendFixture.EventuallyAsync(async () => !(await AgentViewAsync(agent))["probe_connected"]!.GetValue()); - } - } - - [Fact] - public async Task Test_IsRefusedWhenNoProbeIsConnected_AndWhenAPhaseExceedsTheLocalLimits() + public async Task Test_IsRefusedWhenNoDialerIsConnected_AndWhenAPhaseExceedsTheLocalLimits() { await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20); await agent.OverrideLineTasksAsync(); var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.QuicPorts[0]) } }; - var run = new TestRunParams { TestId = new TestId("tst_x"), ProbeId = new ProbeId("prb_nobody"), Plan = "x", Phases = [phase] }; - Assert.Equal("probe_offline", agent.App.Tests.Start(run).Error.Code.Value); + var run = new TestRunParams { TestId = new TestId("tst_x"), DialerId = new NodeId("agt_nobody"), Plan = "x", Phases = [phase] }; + Assert.Equal("dialer_offline", agent.App.Tests.Start(run).Error.Code.Value); - await using ProbeSimulator probe = await agent.ConnectProbeAsync(); - run.ProbeId = new ProbeId(probe.ProbeId); + await using DialerSimulator dialer = await agent.ConnectDialerAsync(); + run.DialerId = new NodeId(dialer.DialerId); phase.Params["mbps"] = System.Text.Json.JsonSerializer.SerializeToElement(50); Assert.Equal("budget_exceeded", agent.App.Tests.Start(run).Error.Code.Value); } diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs index f6fd72e..271bd35 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs @@ -64,31 +64,37 @@ public sealed class AgentHarness : IAsyncDisposable LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory); var app = new AgentApp(config, identity, directory, TimeProvider.System, NullNodeLog.Instance); var harness = new AgentHarness(backend, directory, identity, app); - await app.Ready.WaitAsync(TimeSpan.FromSeconds(10)); + await Task.WhenAny(app.Ready, harness._run).WaitAsync(TimeSpan.FromSeconds(10)); + if (harness._run.IsCompleted) + { + await harness._run; + } + + await app.Ready; await BackendFixture.EventuallyAsync(() => Task.FromResult(app.Link.IsConnected), because: "the agent should connect to the backend"); return harness; } - /// 注册一个 Probe(密钥由它自己生成),并构造连到这个 Agent 的假 Probe:对等面用它的对等面密钥。 - public async Task CreateProbeAsync() + /// 注册一个拨号侧 Agent(密钥由它自己生成),并构造连到这个 Agent 的拨号侧模拟器:对等面用它的对等面密钥。 + public async Task CreateDialerAsync() { - using EnrolledProbe probe = await Backend.CreateProbeAsync(); - // 假 Probe 要在注册目录被释放之后继续用这把密钥:导出再导入一份 - NodeIdentity key = NodeIdentity.FromPkcs8(probe.Identity.PeerIdentity.ExportPkcs8()); + using EnrolledDialer dialer = await Backend.CreateDialerAsync(); + // 拨号侧模拟器 要在注册目录被释放之后继续用这把密钥:导出再导入一份 + NodeIdentity key = NodeIdentity.FromPkcs8(dialer.Identity.PeerIdentity.ExportPkcs8()); string publicKey = Convert.ToBase64String(key.PublicKeySpki); // 等 Agent 的期望状态里有了这把公钥(否则握手会被拒绝) await BackendFixture.EventuallyAsync( - () => Task.FromResult(App.IsPeerKnown(probe.ProbeId, publicKey)), - because: "the agent should learn the probe's peer key"); - return new ProbeSimulator(probe.ProbeId, key); + () => Task.FromResult(App.IsPeerKnown(dialer.DialerId, publicKey)), + because: "the agent should learn the dialer's peer key"); + return new DialerSimulator(dialer.DialerId, key); } - public async Task ConnectProbeAsync() + public async Task ConnectDialerAsync() { - ProbeSimulator probe = await CreateProbeAsync(); - await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.QuicPorts[0], NodeId); - return probe; + DialerSimulator dialer = await CreateDialerAsync(); + await dialer.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.QuicPorts[0], NodeId); + return dialer; } /// 为这个 Agent 创建覆盖任务,让线路任务使用它实际监听的端口(默认模板里是 7443/7444,不在本地允许的端口里)。 diff --git a/backend/tests/MoShiro.Backend.Tests/AgentMeshTests.cs b/backend/tests/MoShiro.Backend.Tests/AgentMeshTests.cs new file mode 100644 index 0000000..b561aa9 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/AgentMeshTests.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Text.Json.Nodes; + +namespace MoShiro.Backend.Tests; + +[Trait("Category", "Slow")] +public sealed class AgentMeshTests : IClassFixture +{ + private readonly BackendFixture _backend; + + public AgentMeshTests(BackendFixture backend) => _backend = backend; + + [Fact] + public async Task ListeningAgents_MeasureBothDirections_AndReportThroughTheirBackendLinks() + { + await using AgentHarness a = await AgentHarness.StartAsync(_backend); + await using AgentHarness b = await AgentHarness.StartAsync(_backend); + await a.OverrideLineTasksAsync(rateHz: 30); + await b.OverrideLineTasksAsync(rateHz: 30); + await BackendFixture.EventuallyAsync( + () => Task.FromResult(a.App.Dialer.IsConnected(b.Id) && b.App.Dialer.IsConnected(a.Id)), + TimeSpan.FromSeconds(30), "both listening agents should establish outgoing measurement sessions"); + + using HttpResponseMessage retry = await _backend.Admin.PostAsync($"/api/admin/links/{a.Id}/{b.Id}/retry", null); + Assert.Equal(HttpStatusCode.Accepted, retry.StatusCode); + JsonNode result = JsonNode.Parse(await retry.Content.ReadAsStringAsync())!; + Assert.True(result["queued"]!.GetValue()); + + await BackendFixture.EventuallyAsync(async () => + { + JsonNode matrix = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"); + return matrix["items"]!.AsArray().Count(i => + ((i!["dialer_id"]!.GetValue() == a.Id && i["listener_id"]!.GetValue() == b.Id) + || (i["dialer_id"]!.GetValue() == b.Id && i["listener_id"]!.GetValue() == a.Id)) + && i["connected"]!.GetValue()) == 2; + }, TimeSpan.FromSeconds(30), "each agent heartbeat should report its outgoing connection"); + + await BackendFixture.EventuallyAsync(async () => + { + JsonNode left = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={a.Id}"); + JsonNode right = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={b.Id}"); + return left["items"]!.AsArray().Any(c => c!["kind"]!.GetValue() == "udp_echo" && c["series"]!.GetValue() == "line:" + b.Id) + && right["items"]!.AsArray().Any(c => c!["kind"]!.GetValue() == "udp_echo" && c["series"]!.GetValue() == "line:" + a.Id); + }, TimeSpan.FromSeconds(40), "both directed lines should persist measured packet data"); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/AgentTaskSetEndToEndTests.cs b/backend/tests/MoShiro.Backend.Tests/AgentTaskSetEndToEndTests.cs index 028ca61..01c743e 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentTaskSetEndToEndTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentTaskSetEndToEndTests.cs @@ -5,7 +5,7 @@ using MoShiro.TestSupport; namespace MoShiro.Backend.Tests; -/// Exercises the complete Backend → Agent → Probe desired task update with an isolated Backend fixture. +/// Exercises the complete Backend → Agent → Dialer desired task update with an isolated Backend fixture. [Trait("Category", "Slow")] public sealed class AgentTaskSetEndToEndTests : IClassFixture { @@ -17,12 +17,12 @@ public sealed class AgentTaskSetEndToEndTests : IClassFixture } [Fact] - public async Task LineTaskSet_FollowsTheDesiredState_AndIsPushedToTheProbe() + public async Task LineTaskSet_FollowsTheDesiredState_AndIsPushedToTheDialer() { await using AgentHarness agent = await AgentHarness.StartAsync(_backend); await agent.OverrideLineTasksAsync(rateHz: 30); - await using ProbeSimulator probe = await agent.ConnectProbeAsync(); - LineTask udp = (await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]))) + await using DialerSimulator dialer = await agent.ConnectDialerAsync(); + LineTask udp = (await dialer.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]))) .Tasks.Single(t => t.Kind == "udp_echo"); JsonNode tasks = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/tasks"); @@ -30,7 +30,7 @@ public sealed class AgentTaskSetEndToEndTests : IClassFixture using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/tasks/" + mine["id"]!.GetValue(), BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json")); disabled.EnsureSuccessStatusCode(); - LineTaskSet next = await probe.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "udp_echo") && s.Version > 0); + LineTaskSet next = await dialer.WaitForTasksAsync(s => s.Tasks.All(t => t.Kind != "udp_echo") && s.Version > 0); Assert.DoesNotContain(next.Tasks, t => t.Id == udp.Id); } } diff --git a/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs index e1d4d05..b091597 100644 --- a/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs @@ -1,3 +1,4 @@ +using Fleet.Core; using System.Net.Http.Headers; using System.Text.Json.Nodes; using MoShiro.Protocol; @@ -37,15 +38,15 @@ public sealed class AotSmokeTests using HttpResponseMessage settingsResponse = await http.PatchAsync("/api/admin/settings", BackendFixture.Json(new JsonObject { ["site.public"] = true }, "application/merge-patch+json")); settingsResponse.EnsureSuccessStatusCode(); - // Probe:probe 角色的加入令牌 → 节点自己生成密钥并注册 - using HttpResponseMessage probeJoin = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["max_uses"] = 1 })); - probeJoin.EnsureSuccessStatusCode(); - string probeToken = JsonNode.Parse(await probeJoin.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); - using var probeHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; - Fleet.Core.Result probeEnrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync( - probeHttp, baseAddress, probeToken, "aot-probe-" + Guid.NewGuid().ToString("N")[..6], Path.Combine(Path.GetTempPath(), "moshiro-aot-probe-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default); - Assert.True(probeEnrolled.IsSuccess, probeEnrolled.IsSuccess ? "" : probeEnrolled.Error.ToString()); - string probeId = probeEnrolled.Value.NodeId; + // 拨号侧 Agent:统一加入令牌 → 节点自己生成密钥并注册 + using HttpResponseMessage dialerJoin = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["max_uses"] = 1 })); + dialerJoin.EnsureSuccessStatusCode(); + string dialerToken = JsonNode.Parse(await dialerJoin.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); + using var dialerHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + Fleet.Core.Result dialerEnrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync( + dialerHttp, baseAddress, dialerToken, "aot-dialer-" + Guid.NewGuid().ToString("N")[..6], Path.Combine(Path.GetTempPath(), "moshiro-aot-dialer-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default); + Assert.True(dialerEnrolled.IsSuccess, dialerEnrolled.IsSuccess ? "" : dialerEnrolled.Error.ToString()); + string dialerId = dialerEnrolled.Value.NodeId; // Agent:加入令牌 → 注册(ECDSA 持有证明)→ WebSocket 签名认证 → 期望状态 using HttpResponseMessage joinResponse = await http.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["max_uses"] = 1 })); @@ -53,8 +54,8 @@ public sealed class AotSmokeTests string joinToken = JsonNode.Parse(await joinResponse.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); using var anonymous = new HttpClient { BaseAddress = baseAddress, Timeout = TimeSpan.FromSeconds(15) }; await using AgentSimulator agent = await AgentSimulator.EnrollAsync(anonymous, joinToken, "aot-" + Guid.NewGuid().ToString("N")[..6]); - agent.ProbeForChunks = new ProbeId(probeId); - string lineId = $"{agent.NodeId.Value}~{probeId}"; + agent.DialerForChunks = new NodeId(dialerId); + string lineId = $"{agent.NodeId.Value}~{dialerId}"; await agent.ConnectAsync(baseAddress); TaskSet tasks = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 2); Assert.Contains(tasks.Tasks, t => t.Kind == "udp_echo"); @@ -98,8 +99,8 @@ public sealed class AotSmokeTests await using SseClient sse = await SseClient.ConnectAsync(http, $"/api/public/live?topics=nodes,series:{lineId}:1s:udp"); await sse.WaitForAsync(e => e.Name == "series"); - // 按需测试:Probe 事件 → 下发 → 状态 - await agent.SendEventAsync(AgentEventKinds.ProbeConnected, probeId); + // 按需测试:Dialer 事件 → 下发 → 状态 + await agent.SendEventAsync(AgentEventKinds.DialerConnected, dialerId); JsonNode started = null!; long begun = TimeProvider.System.GetTimestamp(); while (TimeProvider.System.GetElapsedTime(begun) < TimeSpan.FromSeconds(10)) diff --git a/backend/tests/MoShiro.Backend.Tests/ArchitectureTests.cs b/backend/tests/MoShiro.Backend.Tests/ArchitectureTests.cs index 65737c8..dae8bf5 100644 --- a/backend/tests/MoShiro.Backend.Tests/ArchitectureTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ArchitectureTests.cs @@ -99,16 +99,12 @@ public sealed class ArchitectureTests Assert.DoesNotContain(agent.ProjectReferences, r => r.StartsWith("MoShiro.Backend", StringComparison.Ordinal) || r.StartsWith("Fleet.Server", StringComparison.Ordinal) || r.StartsWith("Fleet.Enrollment", StringComparison.Ordinal)); Assert.Equal(["Fleet.Hosting", "Fleet.Link", "MoShiro.Measurement"], agent.ProjectReferences.Order().ToArray()); - // Probe 同理,并且不认识 Backend 和 Agent - Project probe = Get("MoShiro.Probe"); - Assert.Equal(["Fleet.Hosting", "Fleet.Link", "MoShiro.Measurement"], probe.ProjectReferences.Order().ToArray()); - // 两个节点的共享库只依赖协议 // 测量库在 TCP 数据面上做预握手敲门,所以直接引用 Fleet.Identity(节点侧库) Assert.Equal(["Fleet.Identity", "MoShiro.Protocol"], Get("MoShiro.Measurement").ProjectReferences.Order().ToArray()); // 节点一侧的库没有运行时依赖包,并使用节点禁用列表(不能用 HttpClient、Process、写文件;唯一的写文件豁免在 ConfigStore) - foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "MoShiro.Measurement", "MoShiro.Agent", "MoShiro.Probe" }) + foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "MoShiro.Measurement", "MoShiro.Agent" }) { Project project = Get(name); Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers)); @@ -126,9 +122,9 @@ public sealed class ArchitectureTests [Fact] public void CollectionLayer_KnowsNoBusinessProtocolsOrPlans_OnlyMechanismsAndRawColumns() { - // 采集层(协议、积木、Agent、Probe、Fleet)只认识机制(传输、节奏、方向、时间戳)和原始列; + // 采集层(协议、积木、Agent、Fleet)只认识机制(传输、节奏、方向、时间戳)和原始列; // REALITY、Hysteria2、Minecraft 这些业务概念以及由原语编排成的测试方案只存在于 Backend。 - string[] collectionRoots = ["shared/MoShiro.Protocol", "shared/MoShiro.Measurement", "agent/src", "probe/src", "fleet/src"]; + string[] collectionRoots = ["shared/MoShiro.Protocol", "shared/MoShiro.Measurement", "agent/src", "fleet/src"]; string[] forbidden = ["reality", "hysteria", "hy2", "minecraft", "brutal", "PlanSpec", "PhaseSpec"]; foreach (string root in collectionRoots) { diff --git a/backend/tests/MoShiro.Backend.Tests/AuditTests.cs b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs index 2554294..e9fa128 100644 --- a/backend/tests/MoShiro.Backend.Tests/AuditTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs @@ -34,7 +34,7 @@ public sealed class AuditTests : IClassFixture public async Task WriteActions_AreRecorded_WithWhoAndTheDetail() { string note = "audit-" + Guid.NewGuid().ToString("N")[..8]; - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["note"] = note })); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["note"] = note })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); JsonNode? row = await FindAsync("limit=100", x => x["detail"]?.GetValue().Contains(note, StringComparison.Ordinal) == true); diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index 0242576..45943b9 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -61,34 +61,34 @@ public class BackendFixture : IAsyncLifetime public IServiceProvider Services => App.Services; - /// 夹具创建的默认 Probe:合成块()和单线路的查询都用它。 - public string DefaultProbeId { get; private set; } = ""; + /// 夹具创建的默认拨号侧 Agent:合成块()和单线路的查询都用它。 + public string DefaultDialerId { get; private set; } = ""; - /// 默认 Probe 与这个 Agent 组成的线路 id(agent~probe)。 - public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultProbeId}"; + /// 默认拨号侧 Agent 与这个 Agent 组成的线路 id(agent~dialer)。 + public string Line(Fleet.Core.NodeId agent) => $"{agent.Value}~{DefaultDialerId}"; - public string Line(string agentId) => $"{agentId}~{DefaultProbeId}"; + public string Line(string agentId) => $"{agentId}~{DefaultDialerId}"; /// - /// 注册一个新的 Probe:和真实部署一样,先在管理端创建 probe 角色的加入令牌,再由节点自己生成密钥、用 fleet-enroll 同样的流程注册。 + /// 注册一个新的 Agent:和真实部署一样,先在管理端创建 agent 加入令牌,再由节点自己生成密钥、用 fleet-enroll 同样的流程注册。 /// 返回的身份目录里有两把密钥:连面板的 Identity 和对等面的 PeerIdentity。调用方负责释放。 /// - public async Task CreateProbeAsync(string? name = null) + public async Task CreateDialerAsync(string? name = null) { - string token = await CreateJoinTokenAsync(role: "probe"); - string directory = Path.Combine(Path.GetTempPath(), "moshiro-probe-id-" + Guid.NewGuid().ToString("N")); + string token = await CreateJoinTokenAsync(role: "agent"); + string directory = Path.Combine(Path.GetTempPath(), "moshiro-dialer-id-" + Guid.NewGuid().ToString("N")); using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; Fleet.Core.Result enrolled = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync( - http, BaseAddress, token, name ?? UniqueName("probe"), directory, "0.0.0-test", null, TimeProvider.System, default); + http, BaseAddress, token, name ?? UniqueName("dialer"), directory, "0.0.0-test", null, TimeProvider.System, default); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); - return new EnrolledProbe(enrolled.Value.NodeId, directory, Fleet.Hosting.LoadedNodeIdentity.Load(directory)); + return new EnrolledDialer(enrolled.Value.NodeId, directory, Fleet.Hosting.LoadedNodeIdentity.Load(directory)); } - /// 注册一个 Probe,只要它的 id(密钥用完即释放)。 - public async Task CreateProbeIdAsync(string? name = null) + /// 注册一个拨号侧 Agent,只要它的 id(密钥用完即释放)。 + public async Task CreateDialerIdAsync(string? name = null) { - using EnrolledProbe probe = await CreateProbeAsync(name); - return probe.ProbeId; + using EnrolledDialer dialer = await CreateDialerAsync(name); + return dialer.DialerId; } public async ValueTask InitializeAsync() @@ -97,9 +97,9 @@ public class BackendFixture : IAsyncLifetime await StartAsync(); _adminToken = await CreateAdminTokenAsync(); CreateClients(); - using (EnrolledProbe probe = await CreateProbeAsync("default")) + using (EnrolledDialer dialer = await CreateDialerAsync("default")) { - DefaultProbeId = probe.ProbeId; + DefaultDialerId = dialer.DialerId; } } @@ -138,7 +138,7 @@ public class BackendFixture : IAsyncLifetime public async Task ReconnectAsync(AgentSimulator previous) { AgentSimulator agent = AgentSimulator.Restore(previous.ExportedKey, previous.NodeId); - agent.ProbeForChunks = new MoShiro.Protocol.ProbeId(DefaultProbeId); + agent.DialerForChunks = new Fleet.Core.NodeId(DefaultDialerId); _agents.Add(agent); await agent.ConnectAsync(BaseAddress); return agent; @@ -207,7 +207,7 @@ public class BackendFixture : IAsyncLifetime { string token = await CreateJoinTokenAsync(autoApprove); AgentSimulator agent = await AgentSimulator.EnrollAsync(Anonymous, token, name ?? UniqueName("agent"), kinds); - agent.ProbeForChunks = new MoShiro.Protocol.ProbeId(DefaultProbeId); + agent.DialerForChunks = new Fleet.Core.NodeId(DefaultDialerId); _agents.Add(agent); await agent.ConnectAsync(BaseAddress, hello, kinds); return agent; @@ -340,8 +340,8 @@ public sealed class StrictLoginFixture : BackendFixture protected override int LoginAttemptsPerMinute => 10; } -/// 注册好的 Probe:身份目录(节点自己生成的两把密钥)和加载出来的身份。 -public sealed record EnrolledProbe(string ProbeId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable +/// 注册好的拨号侧 Agent:身份目录(节点自己生成的两把密钥)和加载出来的身份。 +public sealed record EnrolledDialer(string DialerId, string Directory, Fleet.Hosting.LoadedNodeIdentity Identity) : IDisposable { public void Dispose() => Identity.Dispose(); } diff --git a/backend/tests/MoShiro.Backend.Tests/DialerEndToEndTests.cs b/backend/tests/MoShiro.Backend.Tests/DialerEndToEndTests.cs new file mode 100644 index 0000000..3abadf6 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/DialerEndToEndTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json.Nodes; + +namespace MoShiro.Backend.Tests; + +/// 真实的后端 + 真实的 Agent + 真实的拨号侧 Agent(都在进程内,回环网络)。 +// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh +[Trait("Category", "Slow")] +public sealed class DialerEndToEndTests : IClassFixture +{ + private readonly BackendFixture _backend; + + public DialerEndToEndTests(BackendFixture backend) + { + _backend = backend; + } + + private async Task AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id); + + private async Task ChunkCountAsync(AgentHarness agent, string kind) => + (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue() == kind); + + [Fact] + public async Task Dialer_FollowsTheAgentListThePanelKnows_AndSavesItForTheNextStart() + { + await using AgentHarness first = await AgentHarness.StartAsync(_backend); + await using DialerHarness dialer = await DialerHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(() => Task.FromResult(dialer.App.Dialer.IsConnected(first.Id))); + + await using AgentHarness second = await AgentHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(() => Task.FromResult(dialer.App.Dialer.IsConnected(second.Id)), TimeSpan.FromSeconds(30), "a new Agent should arrive through dial_targets state sync: " + string.Join(";", dialer.App.Dialer.Directory.Targets.Select(a => $"{a.NodeId}@{a.Host}:{a.TcpPort}")) + " v" + dialer.App.Dialer.Directory.Version + " want " + second.Id); + Assert.Contains(dialer.App.Dialer.Directory.Targets, a => a.NodeId == second.Id); + Assert.True(File.Exists(Path.Combine(dialer.Directory, "dial_targets.json"))); + + // 停用一个 Agent:Dialer 断开它 + using HttpResponseMessage disabled = await _backend.Admin.PostAsync($"/api/admin/agents/{second.Id}/disable", BackendFixture.Json(new JsonObject())); + Assert.True(disabled.IsSuccessStatusCode, await disabled.Content.ReadAsStringAsync()); + await BackendFixture.EventuallyAsync(() => Task.FromResult(!dialer.App.Dialer.IsConnected(second.Id)), TimeSpan.FromSeconds(30)); + } + + [Fact] + public async Task Hy2Ladder_WithTheRealDialer_Succeeds_AndEveryFragmentIsStored() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await agent.OverrideLineTasksAsync(); + await using DialerHarness dialer = await DialerHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["dialer_connected"]!.GetValue()); + + using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{dialer.DialerId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } })); + Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); + string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + + JsonNode test = null!; + await BackendFixture.EventuallyAsync( + async () => + { + test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId); + return test["status"]!.GetValue() is "succeeded" or "failed" or "aborted"; + }, + TimeSpan.FromSeconds(90)); + Assert.True(test["status"]!.GetValue() == "succeeded", test.ToJsonString()); + + await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments"); + Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); + } + + [Fact] + public async Task RealitySession_WithTheRealDialer_RunsTcpBulkAndStoresBytesAndKernelRows() + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend); + await agent.OverrideLineTasksAsync(); + await using DialerHarness dialer = await DialerHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["dialer_connected"]!.GetValue()); + + using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{dialer.DialerId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); + Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); + string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); + + JsonNode test = null!; + await BackendFixture.EventuallyAsync( + async () => + { + test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId); + return test["status"]!.GetValue() is "succeeded" or "failed" or "aborted"; + }, + TimeSpan.FromSeconds(150)); + Assert.True(test["status"]!.GetValue() == "succeeded", test.ToJsonString()); + await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "tcp_bulk") >= 1, because: "the bulk phase stores its fragments"); + Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/DialerHarness.cs b/backend/tests/MoShiro.Backend.Tests/DialerHarness.cs new file mode 100644 index 0000000..9ffed8e --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/DialerHarness.cs @@ -0,0 +1,62 @@ +using Fleet.Core; +using Fleet.Hosting; +using MoShiro.Agent; + +namespace MoShiro.Backend.Tests; + +/// 进程内的真实拨号节点:用加入令牌注册出来的身份启动 ,监听关闭(listen.enabled = false),只向 Backend 下发的目标拨出。 +public sealed class DialerHarness : IAsyncDisposable +{ + private readonly CancellationTokenSource _stop = new(); + private readonly string _directory; + private readonly Task _run; + private readonly EnrolledDialer _enrolled; + + private DialerHarness(string directory, AgentApp app, EnrolledDialer enrolled) + { + _directory = directory; + _enrolled = enrolled; + App = app; + _run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None); + } + + public AgentApp App { get; } + + public string Directory => _directory; + + public string DialerId { get; init; } = ""; + + public static async Task StartAsync(BackendFixture backend, Action? configure = null) + { + EnrolledDialer enrolled = await backend.CreateDialerAsync(); + var config = new AgentConfig { Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = enrolled.Directory }, Listen = { Enabled = false } }; + configure?.Invoke(config); + var app = new AgentApp(config, enrolled.Identity, enrolled.Directory, TimeProvider.System, NullNodeLog.Instance); + return new DialerHarness(enrolled.Directory, app, enrolled) { DialerId = enrolled.DialerId }; + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + try + { + await _run.WaitAsync(TimeSpan.FromSeconds(15)); + } + catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) + { + // 停止中 + } + + await App.DisposeAsync(); + _enrolled.Dispose(); + _stop.Dispose(); + try + { + System.IO.Directory.Delete(_directory, recursive: true); + } + catch (IOException) + { + // 临时目录清理失败不影响测试结果 + } + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs b/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs index fd80370..f4a50ad 100644 --- a/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/EnrollmentAndTaskSetTests.cs @@ -95,7 +95,7 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture [Fact] public async Task Enrollment_RejectsATlsPublicKeyThatDoesNotMatchItsFingerprint_AndAnUnknownRole() { - string token = await _backend.CreateJoinTokenAsync(role: "probe"); + string token = await _backend.CreateJoinTokenAsync(role: "agent"); using var mine = Fleet.Identity.NodeIdentity.Generate(); using var tls = Fleet.Identity.NodeIdentity.Generate(); using var impostor = Fleet.Identity.NodeIdentity.Generate(); @@ -352,55 +352,41 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture } [Fact] - public async Task ProbeLifecycle_EnrollRenameRevokeDelete_IsReflectedInEveryAgentsPeerList() + public async Task DialOnlyNode_EnrollDisableDelete_IsReflectedInTheListenersPeerList() { - await using AgentSimulator agent = await _backend.ConnectAgentAsync(); - await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); + await using AgentSimulator listener = await _backend.ConnectAgentAsync(); + await listener.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); - // 创建 Probe 就是创建一个 probe 角色的加入令牌;密钥由节点自己生成,Backend 从不接触私钥 string name = _backend.UniqueName("home"); - using EnrolledProbe probe = await _backend.CreateProbeAsync(name); - string id = probe.ProbeId; - Assert.StartsWith(ProbeId.Prefix, id, StringComparison.Ordinal); - string peerKey = Convert.ToBase64String(probe.Identity.PeerIdentity.PublicKeySpki); - string backendKey = Convert.ToBase64String(probe.Identity.Identity.PublicKeySpki); - Assert.NotEqual(backendKey, peerKey); // 连面板的和连 Agent 的是两把独立的密钥 + using EnrolledDialer dialer = await _backend.CreateDialerAsync(name); + string id = dialer.DialerId; + Assert.StartsWith(AgentIds.Prefix, id, StringComparison.Ordinal); + string peerKey = Convert.ToBase64String(dialer.Identity.PeerIdentity.PublicKeySpki); + string backendKey = Convert.ToBase64String(dialer.Identity.Identity.PublicKeySpki); + Assert.NotEqual(backendKey, peerKey); // 连面板的和连监听方的是两把独立的密钥 - // Agent 的名单里放的是对等面的公钥,不是连面板的那把 - TaskSet withProbe = await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == id && p.PublicKey == peerKey)); - Assert.DoesNotContain(withProbe.Peers, p => p.PublicKey == backendKey); + // 监听方的名单里放的是对等面的公钥,不是连面板的那把 + TaskSet withDialer = await listener.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == id && p.PublicKey == peerKey)); + Assert.DoesNotContain(withDialer.Peers, p => p.PublicKey == backendKey); - // 令牌带角色;面板能列出这个 Probe;重名的注册被拒绝 - JsonNode tokens = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/join-tokens"); - Assert.Contains(tokens["items"]!.AsArray(), t => t!["role"]!.GetValue() == "probe"); - JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/probes"); - Assert.Contains(listed["items"]!.AsArray(), p => p!["id"]!.GetValue() == id && !p["revoked"]!.GetValue()); - string duplicateToken = await _backend.CreateJoinTokenAsync(role: "probe"); + // 重名节点进入待审批状态,尚未获得任务 + string duplicateToken = await _backend.CreateJoinTokenAsync(role: "agent"); using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; Fleet.Core.Result duplicate = await Fleet.Enrollment.Client.EnrollmentClient.EnrollAsync( http, _backend.BaseAddress, duplicateToken, name, Path.Combine(Path.GetTempPath(), "moshiro-dup-" + Guid.NewGuid().ToString("N")), "0.0.0", null, TimeProvider.System, default); - Assert.False(duplicate.IsSuccess); - Assert.Equal("fleet_name_conflict", duplicate.Error.Code.Value); + Assert.True(duplicate.IsSuccess); + JsonNode pending = await AgentAsync(duplicate.Value.NodeId); + Assert.Equal("pending", pending["status"]!.GetValue()); - // 创建身份和轮换密钥的接口已经没有了:换密钥就是吊销之后重新注册 - using HttpResponseMessage oldCreate = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = "x" })); - Assert.True(oldCreate.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed); - using HttpResponseMessage oldRotate = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/rotate", null); - Assert.True(oldRotate.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed); - - // 改名 - using HttpResponseMessage renamed = await _backend.Admin.PatchAsync("/api/admin/probes/" + id, BackendFixture.Json(new JsonObject { ["name"] = name + "-b" })); - Assert.Equal(name + "-b", JsonNode.Parse(await renamed.Content.ReadAsStringAsync())!["name"]!.GetValue()); - - // 吊销:从名单里消失,面板里留着记录 - using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{id}/revoke", null); - Assert.True(JsonNode.Parse(await revoked.Content.ReadAsStringAsync())!["revoked"]!.GetValue()); - await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != id)); + // 停用:从名单里消失,记录还在 + using HttpResponseMessage disabled = await _backend.Admin.PostAsync($"/api/admin/agents/{id}/disable", BackendFixture.Json(new JsonObject())); + Assert.True(disabled.IsSuccessStatusCode); + await listener.WaitForTaskSetAsync(s => s.Peers.All(p => p.NodeId.Value != id)); // 删除 - using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/probes/" + id); + using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/agents/" + id); Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); - using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/probes/" + id); + using HttpResponseMessage gone = await _backend.Admin.GetAsync("/api/admin/agents/" + id); Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode); } diff --git a/backend/tests/MoShiro.Backend.Tests/HostTests.cs b/backend/tests/MoShiro.Backend.Tests/HostTests.cs index 575d38b..1f62e94 100644 --- a/backend/tests/MoShiro.Backend.Tests/HostTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/HostTests.cs @@ -141,14 +141,12 @@ public sealed class HostStatusTests : IClassFixture await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=hosts"); await agent.SendHostAsync(Snapshot(18.5)); SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["id"]!.GetValue() == id); - Assert.Equal("agent", live.Data!["kind"]!.GetValue()); Assert.Equal(18.5, live.Data!["host"]!["cpu_percent"]!.GetValue()); Assert.True(live.Data["online"]!.GetValue()); // REST:最新值,没有过期 JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue() == id)!; - Assert.Equal("agent", item["kind"]!.GetValue()); Assert.False(item["stale"]!.GetValue()); Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue()); Assert.Equal(1000, item["host"]!["net_link_mbps"]!.GetValue()); @@ -160,10 +158,10 @@ public sealed class HostStatusTests : IClassFixture Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase); } - // 线路详情也不泄漏 Probe 的地址和版本 + // 线路详情也不泄漏 Dialer 的地址和版本 string line = (await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/lines/{_backend.Line(id)}")).ToJsonString(); Assert.DoesNotContain("remote", line, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("probe_version", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("dialer_version", line, StringComparison.OrdinalIgnoreCase); // 历史:每分钟一个样本 JsonNode history = null!; @@ -185,47 +183,10 @@ public sealed class HostStatusTests : IClassFixture [Fact] [Trait("Category", "Slow")] - public async Task ARealProbeReportsItsHostStatus_LikeAnAgent_NeverCarriesModelsOrAddresses_AndDisappearsWhenRevoked() + public async Task ADialerThatTurnsReportingOff_SendsNothing() { await _backend.SiteMustBePublicAsync(); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file => file.Reporting.HostIntervalSeconds = 1); - - JsonNode? item = null; - await BackendFixture.EventuallyAsync( - async () => - { - JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); - item = list["items"]!.AsArray().SingleOrDefault(i => i!["id"]!.GetValue() == probe.ProbeId); - return item?["host"] is not null; - }, - TimeSpan.FromSeconds(30), - "the probe's host status shows up"); - Assert.Equal("probe", item!["kind"]!.GetValue()); - Assert.NotNull(item["host"]!["uptime_seconds"]); // 任何平台都读得到运行时长 - foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" }) - { - Assert.DoesNotContain(forbidden, item.ToJsonString(), StringComparison.OrdinalIgnoreCase); - } - - // 历史也按节点 id 取 - using HttpResponseMessage history = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}?range=1h"); - Assert.Equal(System.Net.HttpStatusCode.OK, history.StatusCode); - - // 吊销之后展示页就看不到它了 - using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{probe.ProbeId}/revoke", null); - Assert.True(revoked.IsSuccessStatusCode); - JsonNode after = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); - Assert.DoesNotContain(after["items"]!.AsArray(), i => i!["id"]!.GetValue() == probe.ProbeId); - using HttpResponseMessage gone = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}"); - Assert.Equal(System.Net.HttpStatusCode.NotFound, gone.StatusCode); - } - - [Fact] - [Trait("Category", "Slow")] - public async Task AProbeThatTurnsReportingOff_SendsNothing() - { - await _backend.SiteMustBePublicAsync(); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file => + await using DialerHarness dialer = await DialerHarness.StartAsync(_backend, file => { file.Reporting.HostStats = false; file.Reporting.HostIntervalSeconds = 1; @@ -234,7 +195,7 @@ public sealed class HostStatusTests : IClassFixture await Task.Delay(TimeSpan.FromSeconds(4)); JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts"); - JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue() == probe.ProbeId)!; + JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue() == dialer.DialerId)!; Assert.Null(item["host"]); // 节点还在,但一个字也没上报 } } diff --git a/backend/tests/MoShiro.Backend.Tests/IngestAndQueryTests.cs b/backend/tests/MoShiro.Backend.Tests/IngestAndQueryTests.cs index 7726962..153d638 100644 --- a/backend/tests/MoShiro.Backend.Tests/IngestAndQueryTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/IngestAndQueryTests.cs @@ -34,8 +34,8 @@ public sealed class IngestAndQueryTests : IClassFixture Assert.True(await agent.SendChunkAsync(udp)); if (tcp) { - long probeBase = ChunkFactory.ProbeUs(0); - RawStream info = ChunkFactory.TcpInfo(Enumerable.Range(0, 10).Select(s => (probeBase + (s * 1_000_000L), (byte)0, 45_000u, (uint)(s * 2), (uint)(s * 400)))); + long dialerBase = ChunkFactory.DialerUs(0); + RawStream info = ChunkFactory.TcpInfo(Enumerable.Range(0, 10).Select(s => (dialerBase + (s * 1_000_000L), (byte)0, 45_000u, (uint)(s * 2), (uint)(s * 400)))); RawChunk tcpChunk = ChunkFactory.Echo( agent.NodeId, "tcp_echo", start, 200, 50, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)), extraStreams: [info]); Assert.True(await agent.SendChunkAsync(tcpChunk)); diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index 65ce60d..7d8c45e 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -4,7 +4,7 @@ using System.Text.Json.Nodes; namespace MoShiro.Backend.Tests; /// -/// 一行安装:Agent 和 Probe 共用同一份静态脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行), +/// 一行安装:监听侧和拨号侧 Agent 共用同一份静态脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行), /// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。 /// public sealed class InstallTests : IClassFixture @@ -37,11 +37,10 @@ public sealed class InstallTests : IClassFixture Assert.Equal("", agent["install_commands"]!["openwrt"]!.GetValue()); } - [Theory] - [InlineData("agent")] - [InlineData("probe")] - public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues(string role) + [Fact] + public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues() { + const string role = "agent"; JsonNode created = await CreateAsync(role); string code = created["plaintext"]!.GetValue(); JsonNode commands = created["install_commands"]!; @@ -62,11 +61,10 @@ public sealed class InstallTests : IClassFixture Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符 } - [Theory] - [InlineData("agent")] - [InlineData("probe")] - public async Task ComposeInstall_WritesThePublicValuesToAnEnvFile_AndChecksTheComposeHash(string role) + [Fact] + public async Task ComposeInstall_WritesThePublicValuesToAnEnvFile_AndChecksTheComposeHash() { + const string role = "agent"; JsonNode created = await CreateAsync(role); string code = created["plaintext"]!.GetValue(); JsonNode compose = created["compose"]!; @@ -91,28 +89,14 @@ public sealed class InstallTests : IClassFixture byte[] body = await served.Content.ReadAsByteArrayAsync(); Assert.Equal(compose["sha256"]!.GetValue(), Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(body))); - if (role == "probe") - { - // Docker Desktop 没有宿主网络:macOS 和 Windows 的 .env 里改成 bridge - string macos = compose["macos"]!.GetValue(); - string windows = compose["windows"]!.GetValue(); - Assert.Contains("MOSHIRO_NETWORK_MODE=bridge", macos + windows, StringComparison.Ordinal); - Assert.Contains("docker compose up -d", macos, StringComparison.Ordinal); - Assert.Contains("Get-FileHash compose.yaml", windows, StringComparison.Ordinal); - } - else - { - // Agent 需要 Linux 的宿主网络 - Assert.Equal(string.Empty, compose["macos"]!.GetValue()); - Assert.Equal(string.Empty, compose["windows"]!.GetValue()); - } + Assert.Equal(string.Empty, compose["macos"]!.GetValue()); + Assert.Equal(string.Empty, compose["windows"]!.GetValue()); } - [Theory] - [InlineData("agent")] - [InlineData("probe")] - public async Task QuadletInstall_ChecksTheUnitHash_PointsTheImageAtTheConfiguredRepository_AndLeavesAutoUpdateOn(string role) + [Fact] + public async Task QuadletInstall_ChecksTheUnitHash_PointsTheImageAtTheConfiguredRepository_AndLeavesAutoUpdateOn() { + const string role = "agent"; JsonNode created = await CreateAsync(role); JsonNode quadlet = created["quadlet"]!; string env = quadlet["env"]!.GetValue(); @@ -148,7 +132,7 @@ public sealed class InstallTests : IClassFixture [InlineData("evil.container")] [InlineData("backend.container")] [InlineData("agent")] - public async Task OnlyTheTwoQuadletUnits_AreServed(string file) + public async Task UnknownQuadletUnits_AreRejected(string file) { using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/quadlet/" + file); @@ -157,7 +141,6 @@ public sealed class InstallTests : IClassFixture [Theory] [InlineData("agent.yaml")] - [InlineData("probe.yaml")] [InlineData("backend.yaml")] public async Task TheComposeFiles_AreStatic_PinNothingToABuild_AndKeepTheNodeHardening(string file) { @@ -177,7 +160,7 @@ public sealed class InstallTests : IClassFixture [InlineData("evil.yaml")] [InlineData("agent")] [InlineData("..%2Finstall.sh")] - public async Task OnlyTheThreeComposeFiles_AreServed(string file) + public async Task UnknownComposeFiles_AreRejected(string file) { using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/compose/" + file); @@ -220,15 +203,15 @@ public sealed class InstallTests : IClassFixture [Fact] public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte() { - string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}"""; + string manifest = """{"schema":1,"component":"agent","channel":"dev","image":"registry.example/moshiro-dev/moshiro-agent","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}"""; using HttpResponseMessage published = await _backend.Anonymous.PostAsync("/api/release/offer", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = TestReleaseKey.Sign(manifest) })); Assert.True(published.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict, await published.Content.ReadAsStringAsync()); - string code = (await CreateAsync("probe"))["plaintext"]!.GetValue(); + string code = (await CreateAsync("agent"))["plaintext"]!.GetValue(); using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code); Assert.Equal(HttpStatusCode.OK, served.StatusCode); Assert.Equal("no-store", served.Headers.CacheControl?.ToString()); - Assert.Contains("\"component\":\"probe\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改 + Assert.Contains("\"component\":\"agent\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改 using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code); Assert.Equal(HttpStatusCode.OK, signature.StatusCode); Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync())); @@ -243,14 +226,14 @@ public sealed class InstallTests : IClassFixture Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - JsonNode revoked = await CreateAsync("probe"); + JsonNode revoked = await CreateAsync("agent"); using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue()); Assert.True(delete.IsSuccessStatusCode); using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install/manifest?code=" + revoked["plaintext"]!.GetValue()); Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode); // 用完了(一次性令牌已经注册过一个节点):脚本在动手之前就能从这里知道,给出明确的提示 - JsonNode used = await CreateAsync("probe"); + JsonNode used = await CreateAsync("agent"); string code = used["plaintext"]!.GetValue(); await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { } using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code); diff --git a/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs b/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs index adb00bb..0572b53 100644 --- a/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs +++ b/backend/tests/MoShiro.Backend.Tests/Ipv6Tests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Sockets; using System.Text.Json.Nodes; +using Fleet.Core; using Microsoft.Extensions.DependencyInjection; using MoShiro.Backend.Application; using MoShiro.Protocol; @@ -31,7 +32,7 @@ public sealed class Ipv6Tests : IClassFixture { RawChunk chunk = ChunkFactory.Echo(agent.NodeId, "udp_echo", start + k * 10_000, 100, 100, i => new EchoPacket(false, version == IpVersions.V6 && i % 2 == 0, 20_000, 20_000)); - chunk.Header.ProbeId = new ProbeId(_backend.DefaultProbeId); + chunk.Header.DialerId = new NodeId(_backend.DefaultDialerId); chunk.Header.IpVersion = version; Assert.True(await agent.SendChunkAsync(chunk)); } @@ -75,8 +76,9 @@ public sealed class Ipv6Tests : IClassFixture using HttpResponseMessage valid = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.NodeId.Value, BackendFixture.Json(new JsonObject { ["ipv6_host"] = "::1" })); Assert.True(valid.IsSuccessStatusCode); - ProbeAgentList endpoints = await _backend.Services.GetRequiredService().ProbeAgentsAsync(_backend.DefaultProbeId, default); - Assert.Null(Assert.Single(endpoints.Agents, a => a.AgentId == agent.NodeId).Ipv6Host); + AgentRecord dialer = (await _backend.Services.GetRequiredService().GetAsync(new NodeId(_backend.DefaultDialerId), default))!; + DialTargets endpoints = await _backend.Services.GetRequiredService().DialTargetsAsync(dialer, default); + Assert.Null(Assert.Single(endpoints.Targets, t => t.NodeId == agent.NodeId).Ipv6Host); JsonNode identity = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value); Assert.Equal("::1", identity["ipv6_host"]!.GetValue()); Assert.False(identity["ipv6_supported"]!.GetValue()); @@ -91,16 +93,16 @@ public sealed class Ipv6Tests : IClassFixture using HttpResponseMessage configured = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id, BackendFixture.Json(new JsonObject { ["public_host"] = "127.0.0.1", ["ipv6_host"] = "::1" })); Assert.True(configured.IsSuccessStatusCode, await configured.Content.ReadAsStringAsync()); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); - await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(agent.Id) && probe.App.IsConnected(agent.Id, IpVersions.V6))); + await using DialerHarness dialer = await DialerHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(() => Task.FromResult(dialer.App.Dialer.IsConnected(agent.Id) && dialer.App.Dialer.IsConnected(agent.Id, IpVersions.V6))); await BackendFixture.EventuallyAsync(async () => { JsonNode raw = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/raw/chunks?agent_id=" + agent.Id); - return new[] { "line:" + probe.ProbeId, "line:" + probe.ProbeId + ":ipv6" }.All(series => + return new[] { "line:" + dialer.DialerId, "line:" + dialer.DialerId + ":ipv6" }.All(series => new[] { "udp_echo", "tcp_echo" }.All(kind => raw["items"]!.AsArray().Any(c => c!["series"]!.GetValue() == series && c["kind"]!.GetValue() == kind))); }, TimeSpan.FromSeconds(45)); - string v4 = agent.Id + "~" + probe.ProbeId; + string v4 = agent.Id + "~" + dialer.DialerId; string v6 = v4 + "~ipv6"; foreach (string step in new[] { "1s", "10s" }) { @@ -117,13 +119,13 @@ public sealed class Ipv6Tests : IClassFixture Assert.Contains(overview["lines"]!.AsArray(), l => l!["id"]!.GetValue() == v6); Assert.All(overview["lines"]!.AsArray(), l => Assert.Equal("ipv6", l!["ip_version"]!.GetValue())); - var ipv4Connection = probe.App.Connection(agent.Id); + var ipv4Connection = dialer.App.Dialer.Connection(agent.Id); long previous = agent.App.Counters.Fragments; using HttpResponseMessage disabled = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id, BackendFixture.Json(new JsonObject { ["ipv6_host"] = "" })); Assert.True(disabled.IsSuccessStatusCode); - await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.Connection(agent.Id, IpVersions.V6) is null)); - Assert.Same(ipv4Connection, probe.App.Connection(agent.Id)); - Assert.True(probe.App.IsConnected(agent.Id)); + await BackendFixture.EventuallyAsync(() => Task.FromResult(dialer.App.Dialer.Connection(agent.Id, IpVersions.V6) is null)); + Assert.Same(ipv4Connection, dialer.App.Dialer.Connection(agent.Id)); + Assert.True(dialer.App.Dialer.IsConnected(agent.Id)); await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.Counters.Fragments > previous), TimeSpan.FromSeconds(20)); Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); } @@ -139,12 +141,12 @@ public sealed class Ipv6Tests : IClassFixture using HttpResponseMessage configured = await _backend.Admin.PatchAsync("/api/admin/agents/" + agent.Id, BackendFixture.Json(new JsonObject { ["ipv6_host"] = "::1" })); Assert.True(configured.IsSuccessStatusCode); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); - string v4 = agent.Id + "~" + probe.ProbeId; + await using DialerHarness dialer = await DialerHarness.StartAsync(_backend); + string v4 = agent.Id + "~" + dialer.DialerId; string v6 = v4 + "~ipv6"; await BackendFixture.EventuallyAsync(async () => - (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probes"]!.AsArray() - .Any(p => p!["probe_id"]!.GetValue() == probe.ProbeId && p["ip_version"]!.GetValue() == "ipv6")); + (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["dialers"]!.AsArray() + .Any(p => p!["dialer_id"]!.GetValue() == dialer.DialerId && p["ip_version"]!.GetValue() == "ipv6")); var parameters = plan == "hy2_ladder" ? new JsonObject { ["mbps"] = 1, ["duration_s"] = 1 } : new JsonObject { ["duration_s"] = 1 }; using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{v6}/tests", BackendFixture.Json(new JsonObject { ["plan"] = plan, ["params"] = parameters })); Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); diff --git a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs index 9ed7519..f48dbf8 100644 --- a/backend/tests/MoShiro.Backend.Tests/LinkTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/LinkTests.cs @@ -13,8 +13,8 @@ public sealed class LinkTests : IClassFixture _backend = backend; } - private async Task SetLinkAsync(string probe, string agent, bool? enabled) => - await _backend.Admin.PutAsync($"/api/admin/links/{probe}/{agent}", BackendFixture.Json(new JsonObject { ["enabled"] = enabled })); + private async Task SetLinkAsync(string dialer, string agent, bool? enabled) => + await _backend.Admin.PutAsync($"/api/admin/links/{dialer}/{agent}", BackendFixture.Json(new JsonObject { ["enabled"] = enabled })); private async Task SetAutoLinkAsync(bool on) { @@ -22,47 +22,68 @@ public sealed class LinkTests : IClassFixture response.EnsureSuccessStatusCode(); } - private async Task LinkAsync(string probe, string agent) => - (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["items"]!.AsArray().FirstOrDefault(i => i!["probe_id"]!.GetValue() == probe && i["agent_id"]!.GetValue() == agent); + private async Task LinkAsync(string dialer, string agent) => + (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["items"]!.AsArray().FirstOrDefault(i => i!["dialer_id"]!.GetValue() == dialer && i["listener_id"]!.GetValue() == agent); + + [Fact] + public async Task ListeningAgents_AutomaticallyLinkBothDirections_WithoutSelfLinks() + { + await using AgentSimulator first = await _backend.ConnectAgentAsync(); + await using AgentSimulator second = await _backend.ConnectAgentAsync(); + string a = first.NodeId.Value; + string b = second.NodeId.Value; + Assert.True((await LinkAsync(a, b))!["linked"]!.GetValue()); + Assert.True((await LinkAsync(b, a))!["linked"]!.GetValue()); + Assert.Null(await LinkAsync(a, a)); + using HttpResponseMessage self = await SetLinkAsync(a, a, true); + Assert.Equal(HttpStatusCode.UnprocessableEntity, self.StatusCode); + await first.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == b)); + await second.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == a)); + + using HttpResponseMessage off = await SetLinkAsync(a, b, false); + off.EnsureSuccessStatusCode(); + Assert.False((await LinkAsync(a, b))!["linked"]!.GetValue()); + Assert.True((await LinkAsync(b, a))!["linked"]!.GetValue()); + } [Fact] public async Task Links_FollowTheAutomaticSwitch_AndManualChoicesAlwaysWin() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); - string probe = await _backend.CreateProbeIdAsync(); + string dialer = await _backend.CreateDialerIdAsync(); string agentId = agent.NodeId.Value; try { - // 自动连接(默认开):新 Probe 自动进入这个 Agent 允许的名单 - await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe)); - JsonNode link = (await LinkAsync(probe, agentId))!; + // 自动连接(默认开):新 Dialer 自动进入这个 Agent 允许的名单 + await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == dialer)); + JsonNode link = (await LinkAsync(dialer, agentId))!; Assert.True(link["linked"]!.GetValue()); Assert.Null(link["override"]); // 手动关掉这一对:自动连接开着也不连 - using HttpResponseMessage off = await SetLinkAsync(probe, agentId, false); + using HttpResponseMessage off = await SetLinkAsync(dialer, agentId, false); Assert.Equal(HttpStatusCode.OK, off.StatusCode); - await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != probe)); - Assert.False((await LinkAsync(probe, agentId))!["linked"]!.GetValue()); + await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.NodeId.Value != dialer)); + Assert.False((await LinkAsync(dialer, agentId))!["linked"]!.GetValue()); // 取消手动设置:回到自动连接 - using HttpResponseMessage reset = await SetLinkAsync(probe, agentId, null); + using HttpResponseMessage reset = await SetLinkAsync(dialer, agentId, null); Assert.Equal(HttpStatusCode.OK, reset.StatusCode); - await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe)); + await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == dialer)); // 关掉自动连接:没有手动选中的就都不连 await SetAutoLinkAsync(false); - await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.ProbeId.Value != probe)); + await agent.WaitForTaskSetAsync(s => s.Peers.All(p => p.NodeId.Value != dialer)); Assert.False((await _backend.GetJsonAsync(_backend.Admin, "/api/admin/links"))["auto_link"]!.GetValue()); // 自动连接关着,手动选中的照样连 - await SetLinkAsync(probe, agentId, true); - await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.ProbeId.Value == probe)); - Assert.True((await LinkAsync(probe, agentId))!["override"]!.GetValue()); + await SetLinkAsync(dialer, agentId, true); + await agent.WaitForTaskSetAsync(s => s.Peers.Any(p => p.NodeId.Value == dialer)); + Assert.True((await LinkAsync(dialer, agentId))!["override"]!.GetValue()); } finally { - await SetLinkAsync(probe, agentId, null); + await SetLinkAsync(dialer, agentId, null); await SetAutoLinkAsync(true); // 共用的后端:恢复默认,别影响别的测试 } } @@ -71,25 +92,25 @@ public sealed class LinkTests : IClassFixture public async Task Links_RefuseUnknownNodes() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); - string probe = await _backend.CreateProbeIdAsync(); - using HttpResponseMessage noProbe = await SetLinkAsync("prb_missing", agent.NodeId.Value, false); - Assert.Equal(HttpStatusCode.NotFound, noProbe.StatusCode); - using HttpResponseMessage noAgent = await SetLinkAsync(probe, "agt_missing", false); + string dialer = await _backend.CreateDialerIdAsync(); + using HttpResponseMessage noDialer = await SetLinkAsync("agt_missing", agent.NodeId.Value, false); + Assert.Equal(HttpStatusCode.NotFound, noDialer.StatusCode); + using HttpResponseMessage noAgent = await SetLinkAsync(dialer, "agt_missing", false); Assert.Equal(HttpStatusCode.NotFound, noAgent.StatusCode); } [Fact] - public async Task Links_RetryEnablesPairAndReportsWhenProbeIsOffline() + public async Task Links_RetryEnablesPairAndReportsWhenDialerIsOffline() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); - string probe = await _backend.CreateProbeIdAsync(); + string dialer = await _backend.CreateDialerIdAsync(); - using HttpResponseMessage response = await _backend.Admin.PostAsync($"/api/admin/links/{probe}/{agent.NodeId.Value}/retry", BackendFixture.Json(new JsonObject())); + using HttpResponseMessage response = await _backend.Admin.PostAsync($"/api/admin/links/{dialer}/{agent.NodeId.Value}/retry", BackendFixture.Json(new JsonObject())); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); JsonNode body = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; Assert.False(body["queued"]!.GetValue()); Assert.True(body["link"]!["linked"]!.GetValue()); - Assert.Equal("probe_unavailable", body["link"]!["connection_state"]!.GetValue()); - Assert.Contains("Probe 状态", body["link"]!["connection_reason"]!.GetValue()); + Assert.Equal("dialer_unavailable", body["link"]!["connection_state"]!.GetValue()); + Assert.Contains("拨号节点状态", body["link"]!["connection_reason"]!.GetValue()); } } diff --git a/backend/tests/MoShiro.Backend.Tests/LiveTests.cs b/backend/tests/MoShiro.Backend.Tests/LiveTests.cs index 251ed80..d22581b 100644 --- a/backend/tests/MoShiro.Backend.Tests/LiveTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/LiveTests.cs @@ -52,8 +52,8 @@ public sealed class LiveTests : IClassFixture { await _backend.SiteMustBePublicAsync(); await using AgentSimulator agent = await _backend.ConnectAgentAsync(); - await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId); - await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue()); + await agent.SendEventAsync(AgentEventKinds.DialerConnected, _backend.DefaultDialerId); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["dialer_connected"]!.GetValue()); using HttpResponseMessage accepted = await _backend.Admin.PostAsync( $"/api/admin/lines/{_backend.Line(agent.NodeId.Value)}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); diff --git a/backend/tests/MoShiro.Backend.Tests/MoShiro.Backend.Tests.csproj b/backend/tests/MoShiro.Backend.Tests/MoShiro.Backend.Tests.csproj index 8409a4f..f68b2b3 100644 --- a/backend/tests/MoShiro.Backend.Tests/MoShiro.Backend.Tests.csproj +++ b/backend/tests/MoShiro.Backend.Tests/MoShiro.Backend.Tests.csproj @@ -12,7 +12,6 @@ - diff --git a/backend/tests/MoShiro.Backend.Tests/NodeInfoTests.cs b/backend/tests/MoShiro.Backend.Tests/NodeInfoTests.cs index 2c06e9e..ee68e9d 100644 --- a/backend/tests/MoShiro.Backend.Tests/NodeInfoTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/NodeInfoTests.cs @@ -64,12 +64,12 @@ public sealed partial class NodeInfoTests var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z")); var service = new NodeInfoService(store, geo, time); - await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "10.0.0.5", CancellationToken.None); - await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "127.0.0.1", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "10.0.0.5", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "127.0.0.1", CancellationToken.None); Assert.Empty(geo.Calls); - await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None); - await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.73.168.45", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "223.73.168.45", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "223.73.168.45", CancellationToken.None); Assert.Single(geo.Calls); NodeInfo info = Assert.Single(await service.ListAsync(CancellationToken.None)); @@ -79,10 +79,10 @@ public sealed partial class NodeInfoTests Assert.DoesNotMatch(Ipv4(), info.ToString()); // 地址变了才重新查;过了一周也重新查 - await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "223.5.5.5", CancellationToken.None); Assert.Equal(2, geo.Calls.Count); time.Advance(TimeSpan.FromDays(8)); - await service.ObserveAddressAsync("prb_1", NodeKinds.Probe, "223.5.5.5", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "223.5.5.5", CancellationToken.None); Assert.Equal(3, geo.Calls.Count); } @@ -93,11 +93,11 @@ public sealed partial class NodeInfoTests var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-06T00:00:00Z")); var service = new NodeInfoService(new MemoryStore(), geo, time); - await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None); - await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "8.8.8.8", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "8.8.8.8", CancellationToken.None); Assert.Single(geo.Calls); time.Advance(TimeSpan.FromMinutes(11)); - await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "8.8.8.8", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "8.8.8.8", CancellationToken.None); Assert.Equal(2, geo.Calls.Count); Assert.Null(Assert.Single(await service.ListAsync(CancellationToken.None)).Country); } @@ -109,13 +109,13 @@ public sealed partial class NodeInfoTests var service = new NodeInfoService(new MemoryStore(), new FakeGeo(null), time); Assert.True(service.ShouldRetryGeo("agt_1")); - await service.ObserveAddressAsync("agt_1", NodeKinds.Agent, "", CancellationToken.None); + await service.ObserveAddressAsync("agt_1", "", CancellationToken.None); Assert.False(service.ShouldRetryGeo("agt_1")); time.Advance(TimeSpan.FromMinutes(11)); Assert.True(service.ShouldRetryGeo("agt_1")); var found = new NodeInfoService(new MemoryStore(), new FakeGeo(new GeoInfo("jp", 1, "Example", false)), time); - await found.ObserveAddressAsync("agt_2", NodeKinds.Agent, "8.8.8.8", CancellationToken.None); + await found.ObserveAddressAsync("agt_2", "8.8.8.8", CancellationToken.None); time.Advance(TimeSpan.FromMinutes(11)); Assert.False(found.ShouldRetryGeo("agt_2")); } @@ -125,7 +125,7 @@ public sealed partial class NodeInfoTests { var store = new MemoryStore(); var first = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System); - await first.SetPlatformAsync("agt_1", NodeKinds.Agent, new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None); + await first.SetPlatformAsync("agt_1", new PlatformInfo { Os = "linux", Name = "Debian 12", Arch = "arm64" }, CancellationToken.None); var second = new NodeInfoService(store, new FakeGeo(null), TimeProvider.System); NodeInfo info = Assert.Single(await second.ListAsync(CancellationToken.None)); @@ -190,7 +190,7 @@ public sealed partial class NodeInfoTests /// /// 访客能拿到的东西里不能有 IP 等敏感信息:公开接口的整个响应里不出现 IP 形状的字符串,也没有 ip、city、asn、经纬度这些字段。 -/// Agent 和 Probe 这里是真的连上了(Probe 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。 +/// 监听侧和拨号侧 Agent 这里是真的连上了(Dialer 的来源地址是 127.0.0.1),所以如果哪里把地址带出来了,这里一定能发现。 /// [Trait("Category", "Slow")] public sealed partial class NodePrivacyTests : IClassFixture @@ -210,31 +210,29 @@ public sealed partial class NodePrivacyTests : IClassFixture await _backend.SiteMustBePublicAsync(); await using AgentHarness agent = await AgentHarness.StartAsync(_backend); await agent.OverrideLineTasksAsync(); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); - await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["probe_connected"]!.GetValue()); + await using DialerHarness dialer = await DialerHarness.StartAsync(_backend); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id))["dialer_connected"]!.GetValue()); - // 两端都自报了系统和架构(Agent 在 hello 里,Probe 随列表同步) + // 两个 Agent 都通过各自的 Backend hello 上报系统和架构 JsonNode? agentNode = null; - JsonNode? probeNode = null; + JsonNode? dialerNode = null; await BackendFixture.EventuallyAsync( async () => { JsonNode nodes = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes"); agentNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue() == agent.Id); - probeNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue() == probe.ProbeId); - return agentNode?["os"] is not null && probeNode?["os"] is not null && agentNode["arch"] is not null && probeNode["arch"] is not null; + dialerNode = nodes["items"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue() == dialer.DialerId); + return agentNode?["os"] is not null && dialerNode?["os"] is not null && agentNode["arch"] is not null && dialerNode["arch"] is not null; }, TimeSpan.FromSeconds(40), - "the agent (hello) and the probe (agent list sync) both report their platform"); - Assert.Equal("agent", agentNode!["kind"]!.GetValue()); - Assert.Equal("probe", probeNode!["kind"]!.GetValue()); + "both agents report their platform through their own Backend hello"); string[] paths = [ "/api/public/nodes", "/api/public/hosts", "/api/public/overview?range=1h&metrics=rtt.p50", - $"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + probe.ProbeId)}", + $"/api/public/lines/{Uri.EscapeDataString(agent.Id + "~" + dialer.DialerId)}", "/api/public/catalog/plans", ]; foreach (string path in paths) diff --git a/backend/tests/MoShiro.Backend.Tests/NodeRuntimeLogTests.cs b/backend/tests/MoShiro.Backend.Tests/NodeRuntimeLogTests.cs index 82a32fb..5389e32 100644 --- a/backend/tests/MoShiro.Backend.Tests/NodeRuntimeLogTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/NodeRuntimeLogTests.cs @@ -10,7 +10,7 @@ public sealed class NodeRuntimeLogTests public void DiagnosticFieldsSurviveUpload_WhileSecretsAndOversizedFieldsRemainBounded() { var service = new NodeRuntimeLogService(TimeProvider.System); - var node = new NodeId("prb_diagnostics"); + var node = new NodeId("agt_diagnostics"); Dictionary fields = Enumerable.Range(0, 18).ToDictionary(i => "field_" + i, _ => "value"); fields.Add("api_token", "must-not-survive"); fields.Add("detail", new string('x', 500)); diff --git a/backend/tests/MoShiro.Backend.Tests/OnDemandTestsTests.cs b/backend/tests/MoShiro.Backend.Tests/OnDemandTestsTests.cs index e29ecd8..5c50c98 100644 --- a/backend/tests/MoShiro.Backend.Tests/OnDemandTestsTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OnDemandTestsTests.cs @@ -21,29 +21,29 @@ public sealed class OnDemandTestsTests : IClassFixture private static JsonObject Plan(string plan, JsonObject? parameters = null) => parameters is null ? new JsonObject { ["plan"] = plan } : new JsonObject { ["plan"] = plan, ["params"] = parameters }; - private async Task AgentWithProbeAsync() + private async Task AgentWithDialerAsync() { AgentSimulator agent = await _backend.ConnectAgentAsync(); await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); - await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId); - await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue()); + await agent.SendEventAsync(AgentEventKinds.DialerConnected, _backend.DefaultDialerId); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["dialer_connected"]!.GetValue()); return agent; } private async Task TestAsync(string id) => await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + id); [Fact] - public async Task Start_RequiresAProbeSession_APlanThatExists_AndValidParams() + public async Task Start_RequiresADialerSession_APlanThatExists_AndValidParams() { await using AgentSimulator agent = await _backend.ConnectAgentAsync(); await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount); using HttpResponseMessage offline = await StartAsync(agent, Plan("hy2_ladder")); Assert.Equal(HttpStatusCode.Conflict, offline.StatusCode); - Assert.Equal("probe_offline", JsonNode.Parse(await offline.Content.ReadAsStringAsync())!["code"]!.GetValue()); + Assert.Equal("dialer_offline", JsonNode.Parse(await offline.Content.ReadAsStringAsync())!["code"]!.GetValue()); - await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId); - await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue()); + await agent.SendEventAsync(AgentEventKinds.DialerConnected, _backend.DefaultDialerId); + await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["dialer_connected"]!.GetValue()); using HttpResponseMessage unknownPlan = await StartAsync(agent, Plan("warp_ladder")); Assert.Equal(HttpStatusCode.NotFound, unknownPlan.StatusCode); @@ -69,7 +69,7 @@ public sealed class OnDemandTestsTests : IClassFixture [Trait("Category", "Slow")] public async Task Hy2Ladder_RunsEndToEnd_AndProducesAResultWithScores() { - await using AgentSimulator agent = await AgentWithProbeAsync(); + await using AgentSimulator agent = await AgentWithDialerAsync(); using HttpResponseMessage accepted = await StartAsync(agent, Plan("hy2_ladder")); Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); JsonNode started = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!; @@ -140,7 +140,7 @@ public sealed class OnDemandTestsTests : IClassFixture [Fact] public async Task AgentRefusal_IsSurfacedAndMarksTheTestFailed() { - await using AgentSimulator agent = await AgentWithProbeAsync(); + await using AgentSimulator agent = await AgentWithDialerAsync(); agent.TestRunHandler = _ => (false, "test_busy"); using HttpResponseMessage response = await StartAsync(agent, Plan("hy2_ladder")); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); @@ -160,7 +160,7 @@ public sealed class OnDemandTestsTests : IClassFixture [Fact] public async Task Cancel_ReachesTheAgent_AndOnlyTheOwningAgentCanReportStatus() { - await using AgentSimulator agent = await AgentWithProbeAsync(); + await using AgentSimulator agent = await AgentWithDialerAsync(); await using AgentSimulator other = await _backend.ConnectAgentAsync(); using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session")); Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); @@ -190,7 +190,7 @@ public sealed class OnDemandTestsTests : IClassFixture [Fact] public async Task CancellingATestOfAnOfflineAgent_AbortsItImmediately() { - AgentSimulator agent = await AgentWithProbeAsync(); + AgentSimulator agent = await AgentWithDialerAsync(); using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session")); string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); await agent.DisposeAsync(); @@ -205,7 +205,7 @@ public sealed class OnDemandTestsTests : IClassFixture public async Task TestsOfHiddenLines_AreNotFoundForAnonymousVisitors() { await _backend.SiteMustBePublicAsync(); - await using AgentSimulator agent = await AgentWithProbeAsync(); + await using AgentSimulator agent = await AgentWithDialerAsync(); using HttpResponseMessage accepted = await StartAsync(agent, Plan("reality_session")); string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); @@ -230,7 +230,7 @@ public sealed class OnDemandTestsTests : IClassFixture JsonNode list = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}&kind=udp_echo"); JsonNode item = list["items"]!.AsArray().Single()!; Assert.Equal(chunk.Header.ChunkId.ToString(), item["chunk_id"]!.GetValue()); - Assert.Equal("line:" + _backend.DefaultProbeId, item["series"]!.GetValue()); + Assert.Equal("line:" + _backend.DefaultDialerId, item["series"]!.GetValue()); using HttpResponseMessage download = await _backend.Admin.GetAsync("/api/admin/raw/chunks/" + chunk.Header.ChunkId); Assert.Equal("application/vnd.moshiro.rawchunk", download.Content.Headers.ContentType!.MediaType); diff --git a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs index 863dc76..c49e4b6 100644 --- a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs @@ -57,7 +57,7 @@ public sealed class OperationsAndSecurityTests : IClassFixture // 模拟“这个指标是新增的(或者它的口径变了)”:抹掉各层里已有的 rtt,并让登记的指纹对不上 var store = Service(); - foreach (MetricRow row in await store.ReadAsync(MetricLevel.M1, agent.NodeId, "line:" + _backend.DefaultProbeId, baseMs, baseMs + (2 * Minute), default)) + foreach (MetricRow row in await store.ReadAsync(MetricLevel.M1, agent.NodeId, "line:" + _backend.DefaultDialerId, baseMs, baseMs + (2 * Minute), default)) { await store.PatchAsync(MetricLevel.M1, [row with { Metrics = AccumulatorCodec.Encode(new MetricSet()) }], new HashSet { "rtt" }, default); } @@ -190,7 +190,7 @@ public sealed class OperationsAndSecurityTests : IClassFixture Assert.Equal("fleet_protocol_violation", (await agent.RequestRawAsync(MoShiroMethods.TestRun)).Error!.Data!.Code); Assert.Equal("fleet_protocol_violation", (await agent.RequestRawAsync("fleet.state.sync")).Error!.Data!.Code); - // 一个 Probe 都不是的 Agent 发 Probe 才有的通道:节点面的列块通道(17)没有声明,不会进收件箱 + // Agent 向 Backend 发送仅用于节点对等会话的列块通道(17):节点面未声明此方向,不会进入收件箱 await agent.SendBinaryAsync(OutboxFrame.Build(MoShiroChannels.ColumnBlock, Guid.NewGuid(), [1])); // 反复违规:超过上限(默认 20 次)之后连接被服务端关闭 @@ -226,10 +226,10 @@ public sealed class OperationsAndSecurityTests : IClassFixture { await using AgentSimulator agent = await _backend.ConnectAgentAsync(autoApprove: false); // 状态与事件也不会被接受:监控数据里没有它 - await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId); + await agent.SendEventAsync(AgentEventKinds.DialerConnected, _backend.DefaultDialerId); await Task.Delay(200); JsonNode view = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value); - Assert.False(view["probe_connected"]!.GetValue()); + Assert.False(view["dialer_connected"]!.GetValue()); } [Fact] diff --git a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs index 495c5bf..50d45f9 100644 --- a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs @@ -9,7 +9,7 @@ using MoShiro.Backend.Application; namespace MoShiro.Backend.Tests; /// -/// 每个 Agent 和 Probe 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。 +/// 每个 监听侧和拨号侧 Agent 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。 /// 没有信任根、没选属主、选了操作员集合之外的凭证,都不能创建加入码;安装脚本和容器入口缺了属主也不装。 /// public sealed class OwnerPolicyTests @@ -65,7 +65,7 @@ public sealed class OwnerEndToEndTests : IDisposable, IClassFixture真实的后端 + 真实的 Agent + 真实的 Probe(都在进程内,回环网络)。 -// 慢的端到端测试(真实时间的阶梯、要等测量块出来):CI 里单独一批跑(Category=Slow),不拦着出镜像,见 ci/verify-backend.sh -[Trait("Category", "Slow")] -public sealed class ProbeEndToEndTests : IClassFixture -{ - private readonly BackendFixture _backend; - - public ProbeEndToEndTests(BackendFixture backend) - { - _backend = backend; - } - - private async Task AgentViewAsync(AgentHarness agent) => await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.Id); - - private async Task ChunkCountAsync(AgentHarness agent, string kind) => - (await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.Id}"))["items"]!.AsArray().Count(c => c!["kind"]!.GetValue() == kind); - - [Fact] - public async Task Probe_GetsTheAgentListFromThePanel_RunsTheLineTasks_AndTheBackendStoresMergedChunks() - { - await using AgentHarness agent = await AgentHarness.StartAsync(_backend); - await agent.OverrideLineTasksAsync(rateHz: 30); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); - - await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(agent.Id)), because: "the panel sends the agent list to the probe, which then connects"); - await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); - await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.Connection(agent.Id)!.RoleCount == 2), because: "udp_echo and tcp_echo"); - - // 每 10 秒切一块,回显类再等 2 秒宽限 - await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_echo") >= 1 && await ChunkCountAsync(agent, "tcp_echo") >= 1, TimeSpan.FromSeconds(40)); - Assert.True(agent.App.Counters.Fragments >= 2); - Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); - - // 时钟偏移已经估计出来:进程内两端共用同一个单调时钟,所以偏移应当接近 0(真实线路上是两台机器各自的时钟原点之差) - Assert.InRange(probe.App.Connection(agent.Id)!.ClockOffsetUs, -20_000, 20_000); - } - - [Fact] - public async Task Probe_IntroducesItselfToThePanel_AndARevokedProbeIsLockedOut() - { - await _backend.SiteMustBePublicAsync(); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); - string path = "/api/admin/probes/" + probe.ProbeId; - - // 直连面板:在线,hello 里的系统、架构和版本都被记下来了 - await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, path))["online"]!.GetValue(), because: "the probe links to the panel"); - await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, path))["runtime_status"]!.GetValue() == "probe_paused", because: "a Probe without any enabled Agent endpoints reports paused probing"); - await BackendFixture.EventuallyAsync( - async () => (await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/nodes"))["items"]!.AsArray().Any(n => n!["id"]!.GetValue() == probe.ProbeId && n["os"] is not null && n["arch"] is not null), - because: "the platform comes from the probe's own hello"); - - // 吊销:面板立刻断开它,之后连不上 - using HttpResponseMessage revoked = await _backend.Admin.PostAsync(path + "/revoke", null); - revoked.EnsureSuccessStatusCode(); - await BackendFixture.EventuallyAsync(async () => - !(await _backend.GetJsonAsync(_backend.Admin, path))["online"]!.GetValue() && !probe.App.Link.IsConnected, - because: "a revoked probe is disconnected and cannot come back"); - Assert.Equal("maintenance", (await _backend.GetJsonAsync(_backend.Admin, path))["runtime_status"]!.GetValue()); - } - - [Fact] - public async Task Probe_FollowsTheAgentListThePanelKnows_AndSavesItForTheNextStart() - { - await using AgentHarness first = await AgentHarness.StartAsync(_backend); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); - await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(first.Id))); - - await using AgentHarness second = await AgentHarness.StartAsync(_backend); - await BackendFixture.EventuallyAsync(() => Task.FromResult(probe.App.IsConnected(second.Id)), TimeSpan.FromSeconds(30), "a new agent should be picked up through agents_changed: " + string.Join(";", probe.App.Directory.Agents.Select(a => $"{a.AgentId}@{a.Host}:{a.TcpPort}")) + " v" + probe.App.Directory.Version + " want " + second.Id); - Assert.Contains(probe.App.Directory.Agents, a => a.AgentId == second.Id); - Assert.True(File.Exists(Path.Combine(probe.Directory, "agents.json"))); - - // 停用一个 Agent:Probe 断开它 - using HttpResponseMessage disabled = await _backend.Admin.PostAsync($"/api/admin/agents/{second.Id}/disable", BackendFixture.Json(new JsonObject())); - Assert.True(disabled.IsSuccessStatusCode, await disabled.Content.ReadAsStringAsync()); - await BackendFixture.EventuallyAsync(() => Task.FromResult(!probe.App.IsConnected(second.Id)), TimeSpan.FromSeconds(30)); - } - - [Fact] - public async Task Hy2Ladder_WithTheRealProbe_Succeeds_AndEveryFragmentIsStored() - { - await using AgentHarness agent = await AgentHarness.StartAsync(_backend); - await agent.OverrideLineTasksAsync(); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); - await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); - - using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "hy2_ladder", ["params"] = new JsonObject { ["size"] = 1000, ["duration_s"] = 5 } })); - Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); - string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); - - JsonNode test = null!; - await BackendFixture.EventuallyAsync( - async () => - { - test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId); - return test["status"]!.GetValue() is "succeeded" or "failed" or "aborted"; - }, - TimeSpan.FromSeconds(90)); - Assert.True(test["status"]!.GetValue() == "succeeded", test.ToJsonString()); - - await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "udp_train") >= 5, because: "the 100 Mbit/s phase (5 s of 1000 B packets, over 50000 records) is split into two fragments"); - Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); - } - - [Fact] - public async Task RealitySession_WithTheRealProbe_RunsTcpBulkAndStoresBytesAndKernelRows() - { - await using AgentHarness agent = await AgentHarness.StartAsync(_backend); - await agent.OverrideLineTasksAsync(); - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); - await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["probe_connected"]!.GetValue()); - - using HttpResponseMessage accepted = await _backend.Admin.PostAsync($"/api/admin/lines/{agent.Id}~{probe.ProbeId}/tests", BackendFixture.Json(new JsonObject { ["plan"] = "reality_session" })); - Assert.True(accepted.StatusCode == System.Net.HttpStatusCode.Accepted, await accepted.Content.ReadAsStringAsync()); - string testId = JsonNode.Parse(await accepted.Content.ReadAsStringAsync())!["id"]!.GetValue(); - - JsonNode test = null!; - await BackendFixture.EventuallyAsync( - async () => - { - test = await _backend.GetJsonAsync(_backend.Admin, "/api/public/tests/" + testId); - return test["status"]!.GetValue() is "succeeded" or "failed" or "aborted"; - }, - TimeSpan.FromSeconds(150)); - Assert.True(test["status"]!.GetValue() == "succeeded", test.ToJsonString()); - await BackendFixture.EventuallyAsync(async () => await ChunkCountAsync(agent, "tcp_bulk") >= 1, because: "the bulk phase stores its fragments"); - Assert.Equal(0, agent.App.Counters.FragmentsDiscarded); - } -} diff --git a/backend/tests/MoShiro.Backend.Tests/ProbeHarness.cs b/backend/tests/MoShiro.Backend.Tests/ProbeHarness.cs deleted file mode 100644 index 8c160fb..0000000 --- a/backend/tests/MoShiro.Backend.Tests/ProbeHarness.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Text; -using System.Text.Json.Nodes; -using Fleet.Core; -using MoShiro.Probe; - -namespace MoShiro.Backend.Tests; - -/// 进程内的真实 Probe:用加入令牌注册出来的身份(节点自己生成的两把密钥)启动 。 -public sealed class ProbeHarness : IAsyncDisposable -{ - private readonly CancellationTokenSource _stop = new(); - private readonly string _directory; - private readonly Task _run; - private readonly EnrolledProbe _enrolled; - - private ProbeHarness(string directory, ProbeApp app, EnrolledProbe enrolled) - { - _directory = directory; - _enrolled = enrolled; - App = app; - _run = Task.Run(() => app.RunAsync(_stop.Token), CancellationToken.None); - } - - public ProbeApp App { get; } - - public string Directory => _directory; - - public string ProbeId { get; init; } = ""; - - /// 用加入令牌注册(节点自己生成密钥),把面板地址和身份目录写进 probe.json,然后启动。 - public static async Task StartAsync(BackendFixture backend, Action? configure = null) - { - EnrolledProbe enrolled = await backend.CreateProbeAsync(); - string directory = enrolled.Directory; - string path = Path.Combine(directory, "probe.json"); - var written = new ProbeFile { Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory } }; - await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(written, ProbeConfigContext.Default.ProbeFile), Encoding.UTF8); - - ProbeFile file = Fleet.Hosting.NodeConfig.Load(path, ProbeConfigContext.Default.ProbeFile); - configure?.Invoke(file); - var app = new ProbeApp(file, enrolled.Identity, directory, TimeProvider.System, NullNodeLog.Instance); - return new ProbeHarness(directory, app, enrolled) { ProbeId = enrolled.ProbeId }; - } - - public async ValueTask DisposeAsync() - { - await _stop.CancelAsync(); - try - { - await _run.WaitAsync(TimeSpan.FromSeconds(15)); - } - catch (Exception ex) when (ex is OperationCanceledException or TimeoutException) - { - // 停止中 - } - - App.Dispose(); - _enrolled.Dispose(); - _stop.Dispose(); - try - { - System.IO.Directory.Delete(_directory, recursive: true); - } - catch (IOException) - { - // 临时目录清理失败不影响测试结果 - } - } -} diff --git a/backend/tests/MoShiro.Backend.Tests/ProtocolDeclarationTests.cs b/backend/tests/MoShiro.Backend.Tests/ProtocolDeclarationTests.cs index 0ec3615..5bcfb5c 100644 --- a/backend/tests/MoShiro.Backend.Tests/ProtocolDeclarationTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ProtocolDeclarationTests.cs @@ -2,7 +2,7 @@ using System.Reflection; using Fleet.Rpc; using Fleet.Server; using Microsoft.Extensions.DependencyInjection; -using MoShiro.Probe; +using MoShiro.Agent; using MoShiro.Protocol; namespace MoShiro.Backend.Tests; @@ -55,29 +55,13 @@ public sealed class ProtocolDeclarationTests : IClassFixture } [Fact] - public async Task Agent_HandlesEveryMethodItShould_OnBothPlanes() + public async Task Node_HandlesEveryMethodItShould_OnBothPlanes() { await using AgentHarness agent = await AgentHarness.StartAsync(_backend); - Assert.Empty(s_table.MissingHandlers(RpcSide.Node, NodeRoles.Agent, agent.App.Link.Router.Methods)); - // 对等面:Agent 是监听方,要处理 Probe 发来的方法 + Assert.Empty(s_table.MissingHandlers(RpcSide.Node, null, agent.App.Link.Router.Methods)); + // 对等面:节点作为监听方处理拨号方发来的方法,作为拨号方处理监听方发来的方法 Assert.Empty(s_table.MissingHandlers(RpcSide.Listener, null, agent.App.PeerRouter.Methods)); - } - - [Fact] - public async Task Probe_HandlesEveryMethodItShould_OnBothPlanes() - { - await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend); - Assert.Empty(s_table.MissingHandlers(RpcSide.Node, NodeRoles.Probe, probe.App.Link.Router.Methods)); - // 对等面:Probe 是拨号方,要处理 Agent 发来的方法 - Assert.Empty(s_table.MissingHandlers(RpcSide.Dialer, null, AgentConnection.Router.Methods)); - } - - [Fact] - public void RolesOfNodeIds_FollowThePrefix() - { - Assert.Equal(NodeRoles.Agent, NodeRoles.Of(AgentIds.Prefix + "_01ABC")); - Assert.Equal(NodeRoles.Probe, NodeRoles.Of(ProbeId.Prefix + "_01ABC")); - Assert.Null(NodeRoles.Of("usr_01ABC")); + Assert.Empty(s_table.MissingHandlers(RpcSide.Dialer, null, DialConnection.Router.Methods)); } private static string FindRoot() diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs index 91ce8ad..60132f5 100644 --- a/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs @@ -58,7 +58,7 @@ public sealed class ReleaseFeedTests : IAsyncLifetime } [Theory] - [InlineData("probe", "dev")] // 仓库里没有这个组件的清单 + [InlineData("unsupported", "dev")] // 仓库里没有这个组件的清单 [InlineData("agent", "release")] // 没有这个通道 public async Task NothingThere_MeansNull_NotAnException(string component, string channel) { diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs index ff50210..6008322 100644 --- a/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseOfferTests.cs @@ -76,7 +76,7 @@ public sealed class ReleaseOfferTests : IClassFixture [Fact] public async Task OfferWithAValidSignature_IsAccepted_WithoutAnyToken() { - long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000; // backend 的 release 通道:不和别的用例抢 agent、probe 的 seq + long seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000; // backend 的 release 通道:不和别的用例抢 agent 的 seq string manifest = $$"""{"schema":1,"component":"backend","channel":"release","image":"registry.example/moshiro/moshiro-backend","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-x","version":"0.1.1","seq":{{seq}}}"""; using HttpClient anonymous = _backend.NewClient(); diff --git a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs index 61ac795..f30a9f0 100644 --- a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs @@ -256,7 +256,7 @@ public sealed class StorageTests : IDisposable // ───────────────────────── 节点、令牌、设置、事件 ───────────────────────── private static NewNode Node(string id, string name, NodeStatus status = NodeStatus.Active, byte fill = 1) => - new(new NodeId(id), name, status, new byte[] { fill, 2, 3 }, new byte[32], ["lab"], """{"k":1}""", "1.0", 1_000); + new(new NodeId(id), name, status, new byte[] { fill, 2, 3 }, new byte[32], ["lab"], """{"k":1}""", "1.0", 1_000, TlsPublicKey: new byte[] { fill, 2, 3 }); [Fact] public async Task Agents_NamesAreUniqueAmongEstablishedNodes_PendingOnesMayRepeat() diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index aa1bbec..1af037b 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -33,7 +33,7 @@ public sealed class TrustTests : IClassFixture private static JsonObject Sign(SoftwarePasskey passkey, string statement) => SharedOperator.Sign(passkey, statement); /// 创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。 - private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null) + private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "agent", string[]? controllers = null) { var body = new JsonObject { ["role"] = role, ["note"] = "trust test" }; if (controllers is not null) diff --git a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs index 712def4..ebdd225 100644 --- a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs @@ -51,11 +51,11 @@ public sealed class UpdateTests : IClassFixture await using AgentSimulator late = await _backend.ConnectAgentAsync(); await BackendFixture.EventuallyAsync(() => Task.FromResult(late.UpdateOffers.ToArray().Any(o => o.Manifest == manifest)), because: "a node that connects later still gets the latest offer"); - // 另一个组件(probe)的清单不会发给 Agent - using HttpResponseMessage probeRelease = await PublishAsync(Manifest("probe", seq)); - Assert.Equal(HttpStatusCode.Created, probeRelease.StatusCode); + // 另一个组件(backend)的清单不会发给 Agent + using HttpResponseMessage backendRelease = await PublishAsync(Manifest("backend", seq)); + Assert.Equal(HttpStatusCode.Created, backendRelease.StatusCode); await Task.Delay(300); - Assert.DoesNotContain(online.UpdateOffers.ToArray(), o => o.Manifest.Contains("\"component\":\"probe\"", StringComparison.Ordinal)); + Assert.DoesNotContain(online.UpdateOffers.ToArray(), o => o.Manifest.Contains("\"component\":\"backend\"", StringComparison.Ordinal)); } [Fact] diff --git a/backend/tests/MoShiro.TestSupport/AgentSimulator.cs b/backend/tests/MoShiro.TestSupport/AgentSimulator.cs index c69fbde..c41bfe4 100644 --- a/backend/tests/MoShiro.TestSupport/AgentSimulator.cs +++ b/backend/tests/MoShiro.TestSupport/AgentSimulator.cs @@ -18,7 +18,7 @@ namespace MoShiro.TestSupport; public sealed class AgentSimulator : IAsyncDisposable { private readonly NodeIdentity _identity; - private readonly NodeIdentity _tlsKey = NodeIdentity.Generate(); + private readonly NodeIdentity _tlsKey; private readonly ClientWebSocket _socket = new(); private readonly ConcurrentDictionary> _acks = new(); private readonly ConcurrentDictionary> _responses = new(); @@ -28,9 +28,11 @@ public sealed class AgentSimulator : IAsyncDisposable private int _disposed; private Task? _loop; - private AgentSimulator(NodeIdentity identity, NodeId nodeId, string status) + private AgentSimulator(NodeIdentity identity, NodeIdentity tlsKey, NodeId nodeId, string status, bool listens) { _identity = identity; + _tlsKey = tlsKey; + Listens = listens; ExportedKey = identity.ExportPkcs8(); NodeId = nodeId; EnrollmentStatus = status; @@ -38,6 +40,9 @@ public sealed class AgentSimulator : IAsyncDisposable public NodeId NodeId { get; } + /// 是否像监听的节点那样上报端口(false:只拨出的节点)。 + public bool Listens { get; set; } + /// 身份私钥(PKCS#8):释放之后仍可用它以同一个身份重新连接。 public byte[] ExportedKey { get; } @@ -72,19 +77,19 @@ public sealed class AgentSimulator : IAsyncDisposable public byte[] PublicKey => _identity.PublicKeySpki; - /// hello 里上报的 TLS 证书公钥(Probe 用它固定证书、派生敲门密钥)。 + /// 注册时交给面板的 TLS 证书公钥(拨号方用它固定证书、派生敲门密钥)。 public byte[] TlsPublicKey => _tlsKey.PublicKeySpki; /// 用这个节点的私钥为认证随机数签名(可以冒充别的节点 ID:用来测试服务端的校验)。 public byte[] SignAuth(NodeId claimedNode, string nonce) => _identity.Sign(SignaturePurpose.Auth, claimedNode.Value, nonce); - public static async Task EnrollAsync(HttpClient http, string joinToken, string name, string[]? kinds = null, string publicHost = "203.0.113.10") + public static async Task EnrollAsync(HttpClient http, string joinToken, string name, string[]? kinds = null, string publicHost = "203.0.113.10", bool listens = true) { NodeIdentity identity = NodeIdentity.Generate(); + NodeIdentity tls = NodeIdentity.Generate(); string pubkey = Convert.ToBase64String(identity.PublicKeySpki); byte[] tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(joinToken)); string proof = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), pubkey)); - using NodeIdentity tls = NodeIdentity.Generate(); var body = new JsonObject { ["join_token"] = joinToken, @@ -96,10 +101,9 @@ public sealed class AgentSimulator : IAsyncDisposable ["node_version"] = "0.0.1-sim", ["meta"] = new JsonObject { - ["probe_tcp_port"] = 7443, - ["probe_quic_ports"] = new JsonArray(7443), - ["probe_udp_ports"] = new JsonArray(7443), - ["public_host"] = publicHost, + ["tcp_port"] = listens ? 7443 : 0, + ["quic_ports"] = listens ? new JsonArray(7443) : new JsonArray(), + ["public_host"] = listens ? publicHost : "", ["kinds"] = new JsonArray((kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()), }, }; @@ -110,15 +114,16 @@ public sealed class AgentSimulator : IAsyncDisposable if (!response.IsSuccessStatusCode) { identity.Dispose(); + tls.Dispose(); throw new InvalidOperationException($"enroll failed: {(int)response.StatusCode} {text}"); } JsonObject result = JsonNode.Parse(text)!.AsObject(); - return new AgentSimulator(identity, new NodeId(result["node_id"]!.GetValue()), result["status"]!.GetValue()); + return new AgentSimulator(identity, tls, new NodeId(result["node_id"]!.GetValue()), result["status"]!.GetValue(), listens); } /// 用已有的身份恢复一个模拟器(例如后端重启之后同一个节点重新连接)。 - public static AgentSimulator Restore(byte[] pkcs8, NodeId nodeId) => new(NodeIdentity.FromPkcs8(pkcs8), nodeId, "active"); + public static AgentSimulator Restore(byte[] pkcs8, NodeId nodeId, bool listens = true) => new(NodeIdentity.FromPkcs8(pkcs8), NodeIdentity.Generate(), nodeId, "active", listens); /// 连接并完成认证与 hello。认证失败时抛出异常。 public async Task ConnectAsync(Uri baseAddress, bool sendHello = true, string[]? kinds = null) @@ -150,16 +155,12 @@ public sealed class AgentSimulator : IAsyncDisposable public async Task HelloAsync(string[]? kinds = null) { var meta = JsonSerializer.SerializeToElement( - new AgentHelloMeta + new NodeHelloMeta { - TlsPublicKey = Convert.ToBase64String(_tlsKey.PublicKeySpki), - Addresses = Addresses is null ? null : [.. Addresses], - ProbeTcpPort = 7443, - ProbeQuicPorts = [7443], - LegacyProbeUdpPorts = [7443], + Listen = Listens ? new ListenInfo { TcpPort = 7443, QuicPorts = [7443], Addresses = Addresses is null ? null : [.. Addresses] } : null, LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 }, }, - MoShiroJsonContext.Default.AgentHelloMeta); + MoShiroJsonContext.Default.NodeHelloMeta); var hello = new HelloParams { NodeVersion = "0.0.1-sim", @@ -171,14 +172,14 @@ public sealed class AgentSimulator : IAsyncDisposable } /// 上传一个原始块并等待服务端 ACK(持久化之后才会确认)。 - /// 设置之后,这个假 Agent 发出的每个块都归属于这个 Probe(夹具的默认 Probe)。 - public ProbeId? ProbeForChunks { get; set; } + /// 设置之后,这个假 Agent 发出的每个块都归属于这个拨号方(夹具的默认拨号方)。 + public NodeId? DialerForChunks { get; set; } public async Task SendChunkAsync(RawChunk chunk, TimeSpan? timeout = null) { - if (ProbeForChunks is { } probe) + if (DialerForChunks is { } dialer) { - chunk.Header.ProbeId = probe; + chunk.Header.DialerId = dialer; } byte[] bytes = RawChunkCodec.Encode(chunk); @@ -214,7 +215,7 @@ public sealed class AgentSimulator : IAsyncDisposable public Task SendUpdateStatusAsync(UpdateStatus status) => SendAsync(RpcMessage.Notification( FleetMethods.Heartbeat, - new HeartbeatParams { Extra = JsonSerializer.SerializeToElement(new AgentHeartbeatExtra { Update = status }, MoShiroJsonContext.Default.AgentHeartbeatExtra) }, + new HeartbeatParams { Extra = JsonSerializer.SerializeToElement(new NodeHeartbeatExtra { Update = status }, MoShiroJsonContext.Default.NodeHeartbeatExtra) }, FleetRpcContext.Default.HeartbeatParams)); public async Task RequestRawAsync(string method, JsonElement? parameters = null) => await RequestAsync(method, parameters); diff --git a/backend/tests/MoShiro.TestSupport/ChunkFactory.cs b/backend/tests/MoShiro.TestSupport/ChunkFactory.cs index f093071..cf41653 100644 --- a/backend/tests/MoShiro.TestSupport/ChunkFactory.cs +++ b/backend/tests/MoShiro.TestSupport/ChunkFactory.cs @@ -7,14 +7,14 @@ namespace MoShiro.TestSupport; /// 每一个回显包的设定:上行或下行丢失,以及两个方向的单向时延(微秒)。 public readonly record struct EchoPacket(bool LostUp, bool LostDown, long OwdUpUs, long OwdDownUs, long ProcessUs = 50); -/// 合成原始块:用于测试、模拟器和离线工具。块头里的时钟锚点保证 Probe 与 Agent 时钟相差 。 +/// 合成原始块:用于测试、模拟器和离线工具。块头里的时钟锚点保证 拨号方与监听方时钟相差 。 public static class ChunkFactory { - /// 合成块默认属于哪个 Probe(测试夹具在后端创建一个真实的 Probe 之后把它的 id 写在这里)。 - public static string DefaultProbe { get; set; } = "prb_test"; + /// 合成块默认属于哪个拨号侧 Agent(测试夹具在后端创建一个真实的拨号侧 Agent 之后把它的 id 写在这里)。 + public static string DefaultDialer { get; set; } = "agt_dialer_test"; public const long AgentMonoAnchorUs = 1_000_000_000; - public const long ProbeOffsetUs = 5_000_000; + public const long DialerOffsetUs = 5_000_000; public static RawChunkHeader Header(NodeId agent, string kind, long startUtcMs, int durationMs = 10_000, IReadOnlyDictionary? parameters = null) { @@ -25,17 +25,17 @@ public static class ChunkFactory ChunkId = new ChunkId(Guid.CreateVersion7()), Kind = kind, AgentId = agent, - ProbeId = new ProbeId(DefaultProbe), + DialerId = new NodeId(DefaultDialer), Params = new Dictionary(resolved.Values!.Values), Port = 7443, StartUtcMs = startUtcMs, DurationMs = durationMs, - Clock = new ChunkClock { AgentMonoUs = AgentMonoAnchorUs, UtcMs = startUtcMs, ProbeOffsetUs = ProbeOffsetUs }, + Clock = new ChunkClock { ListenerMonoUs = AgentMonoAnchorUs, UtcMs = startUtcMs, DialerOffsetUs = DialerOffsetUs }, }; } - /// Probe 时钟下,距离块起点 毫秒的时刻(微秒)。 - public static long ProbeUs(long offsetMs) => (offsetMs * 1000) + AgentMonoAnchorUs - ProbeOffsetUs; + /// 拨号方时钟下,距离块起点 毫秒的时刻(微秒)。 + public static long DialerUs(long offsetMs) => (offsetMs * 1000) + AgentMonoAnchorUs - DialerOffsetUs; public static long AgentUs(long offsetMs) => (offsetMs * 1000) + AgentMonoAnchorUs; @@ -54,10 +54,10 @@ public static class ChunkFactory for (int i = 0; i < count; i++) { EchoPacket p = packet(i); - long t1 = ProbeUs((long)i * intervalMs); - long t2 = t1 + ProbeOffsetUs + p.OwdUpUs; + long t1 = DialerUs((long)i * intervalMs); + long t2 = t1 + DialerOffsetUs + p.OwdUpUs; long t3 = t2 + p.ProcessUs; - long t4 = t3 - ProbeOffsetUs + p.OwdDownUs; + long t4 = t3 - DialerOffsetUs + p.OwdDownUs; msg.Add(i, 64, t1, p.LostUp ? null : t2, p.LostUp ? null : t3, p.LostUp || p.LostDown ? null : t4); } @@ -91,14 +91,14 @@ public static class ChunkFactory int i = 0; foreach ((long tcp, long? tls, ConnectOutcomeCode outcome) in attempts) { - builder.Add(i, ProbeUs(i * 5000L), outcome == ConnectOutcomeCode.Timeout ? null : tcp, tls, (long)outcome); + builder.Add(i, DialerUs(i * 5000L), outcome == ConnectOutcomeCode.Timeout ? null : tcp, tls, (long)outcome); i++; } return new RawChunk(Header(agent, "connect", startUtcMs), [builder.Build()]); } - /// udp_train:Agent 以固定间隔发包,Probe 记录收到的时刻; 为 N 时每 N 个包丢一个。 + /// udp_train:Agent 以固定间隔发包,Dialer 记录收到的时刻; 为 N 时每 N 个包丢一个。 public static RawChunk Train(NodeId agent, long startUtcMs, double mbps, int phase, int seconds = 10, int lossEvery = 0, long owdUs = 20_000) { const int size = 1250; @@ -109,8 +109,8 @@ public static class ChunkFactory { long sendAgent = AgentMonoAnchorUs + (long)(i * intervalUs); bool lost = lossEvery > 0 && i % lossEvery == 0; - long recvProbe = sendAgent + owdUs - ProbeOffsetUs; - pkt.Add(i, size, sendAgent, lost ? null : recvProbe); + long recvDialer = sendAgent + owdUs - DialerOffsetUs; + pkt.Add(i, size, sendAgent, lost ? null : recvDialer); } var parameters = new Dictionary diff --git a/backend/tests/MoShiro.TestSupport/ProbeSimulator.cs b/backend/tests/MoShiro.TestSupport/DialerSimulator.cs similarity index 88% rename from backend/tests/MoShiro.TestSupport/ProbeSimulator.cs rename to backend/tests/MoShiro.TestSupport/DialerSimulator.cs index 5d723db..e45dcb0 100644 --- a/backend/tests/MoShiro.TestSupport/ProbeSimulator.cs +++ b/backend/tests/MoShiro.TestSupport/DialerSimulator.cs @@ -14,15 +14,15 @@ using MoShiro.Protocol; namespace MoShiro.TestSupport; /// -/// 假 Probe:通过真实的点对点握手(TLS 固定证书 + 签名 + ECDH)连接真实的 Agent,接收 line_task_set, -/// 通过 QUIC Datagram 发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。 -/// 它实现 Probe 的协议职责,是将来真实 Probe 的参照。 +/// 拨号侧模拟器:通过真实的点对点握手(TLS 固定证书 + 签名 + ECDH)连接真实的 Agent,接收 line_task_set, +/// 通过 QUIC Datagram 发回显包、接收单向数据流、估计时钟偏移,并把 拨号方一侧的列块经发件箱通道 17 交给 Agent。 +/// 它实现 Agent 拨号侧的协议职责,用于验证监听侧和数据合并行为。 /// -public sealed class ProbeSimulator : IAsyncDisposable +public sealed class DialerSimulator : IAsyncDisposable { private readonly NodeIdentity _identity; private byte[]? _agentKey; - private readonly RpcRouter _router; + private readonly RpcRouter _router; private readonly ConcurrentDictionary _acks = new(); private readonly ConcurrentDictionary _echoes = new(); private readonly ConcurrentDictionary _trains = new(); @@ -30,27 +30,26 @@ public sealed class ProbeSimulator : IAsyncDisposable private readonly long _origin = TimeProvider.System.GetTimestamp(); private readonly CancellationTokenSource _stop = new(); private NodeId _agentId; - private RpcSession? _rpc; + private RpcSession? _rpc; private QuicDatagramTransport? _quic; private DataSession? _dataSession; private IPEndPoint? _agentQuic; private PeerSessionInfo? _info; - private Task? _receiver; private Task? _run; - public ProbeSimulator(string probeId, NodeIdentity identity) + public DialerSimulator(string dialerId, NodeIdentity identity) { - ProbeId = probeId; + DialerId = dialerId; _identity = identity; _router = BuildRouter(); } - public string ProbeId { get; } + public string DialerId { get; } - /// 同一个身份的另一个假 Probe(模拟 Probe 重启:新的进程、新的会话,序号从 0 重新开始)。 - public ProbeSimulator Restarted() => new(ProbeId, _identity); + /// 同一个身份的另一个拨号侧模拟器(模拟 Dialer 重启:新的进程、新的会话,序号从 0 重新开始)。 + public DialerSimulator Restarted() => new(DialerId, _identity); - /// true 时拒绝按需测试的阶段(用来测试 Agent 对 Probe 拒绝的处理)。 + /// true 时拒绝按需测试的阶段(用来测试 Agent 对 Dialer 拒绝的处理)。 public bool RefusePhases { get; set; } /// 收到 phase_end 之后不交出列块,只报告 0 个(用来测试 Agent 的等待逻辑)。 @@ -73,7 +72,7 @@ public sealed class ProbeSimulator : IAsyncDisposable using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(10)); _agentId = agentId; - (PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentPublicKey, _identity, ProbeId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, timeout.Token); + (PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentPublicKey, _identity, DialerId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, timeout.Token); _info = info; _agentKey = agentPublicKey; _dataSession = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key); @@ -84,12 +83,10 @@ public sealed class ProbeSimulator : IAsyncDisposable new DataPlaneCounters(), new TestNodeLog(), _agentQuic, - agentPublicKey, - 0); - _rpc = new RpcSession(transport, _router, this, time ?? TimeProvider.System); + agentPublicKey); + _rpc = new RpcSession(transport, _router, this, time ?? TimeProvider.System); _rpc.OnBinary = (_, _) => ValueTask.CompletedTask; _run = _rpc.RunAsync(_stop.Token); - _receiver = _quic.RunAsync(_stop.Token); await SyncClockAsync(ct: timeout.Token); await _quic.ConnectAsync(timeout.Token); } @@ -112,7 +109,7 @@ public sealed class ProbeSimulator : IAsyncDisposable throw new TimeoutException("the agent did not send the expected line task set"); } - /// NTP 式的时钟同步:偏移 = Agent 时钟 − (往返中点的) Probe 时钟。取最小往返的一次。 + /// NTP 式的时钟同步:偏移 = Agent 时钟 − (往返中点的) 拨号方时钟。取最小往返的一次。 public async Task SyncClockAsync(int rounds = 3, CancellationToken ct = default) { long bestRoundTrip = long.MaxValue; @@ -121,7 +118,7 @@ public sealed class ProbeSimulator : IAsyncDisposable long t0 = NowUs(); Result reply = await _rpc!.RequestAsync( MoShiroMethods.Clock, - new ClockParams { ProbeMonoUs = t0 }, + new ClockParams { DialerMonoUs = t0 }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, ct, @@ -136,7 +133,7 @@ public sealed class ProbeSimulator : IAsyncDisposable if (roundTrip < bestRoundTrip) { bestRoundTrip = roundTrip; - ClockOffsetUs = reply.Value.AgentMonoUs - ((t0 + t1) / 2); + ClockOffsetUs = reply.Value.ListenerMonoUs - ((t0 + t1) / 2); } } } @@ -163,7 +160,7 @@ public sealed class ProbeSimulator : IAsyncDisposable _echoes.TryRemove(task.StreamId, out _); _quic.Unregister(task.StreamId); - var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)])); + var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)])); foreach ((uint seq, long t1) in collector.Sent.OrderBy(p => p.Key)) { msg.Add(seq, size, t1, collector.Received.TryGetValue(seq, out long t4) ? t4 : null); @@ -198,7 +195,7 @@ public sealed class ProbeSimulator : IAsyncDisposable await stream.WriteAsync(auth, ct); long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); - var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)])); + var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)])); int received = 0; for (uint seq = 0; seq < count; seq++) { @@ -278,7 +275,7 @@ public sealed class ProbeSimulator : IAsyncDisposable } } - foreach (Task? task in new[] { _run, _receiver }) + foreach (Task? task in new[] { _run }) { if (task is not null) { @@ -306,10 +303,10 @@ public sealed class ProbeSimulator : IAsyncDisposable TaskId = task, TestId = test, Phase = phase, - ProbeId = new ProbeId(ProbeId), + DialerId = new NodeId(DialerId), StartUtcMs = startUtcMs, DurationMs = durationMs, - Clock = new ChunkClock { ProbeOffsetUs = ClockOffsetUs }, + Clock = new ChunkClock { DialerOffsetUs = ClockOffsetUs }, }; if (extra is not null) { @@ -337,17 +334,17 @@ public sealed class ProbeSimulator : IAsyncDisposable } } - private RpcRouter BuildRouter() => - new RpcRouter(RpcSide.Dialer, MoShiroMethodTable.Table) + private RpcRouter BuildRouter() => + new RpcRouter(RpcSide.Dialer, MoShiroMethodTable.Table) .On( FleetMethods.StateSync, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, - (probe, sync, _) => + (dialer, sync, _) => { if (sync.Type == LineTaskSet.StateType) { - probe._taskSets.Enqueue(sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet)!); + dialer._taskSets.Enqueue(sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet)!); } return ValueTask.FromResult(Result.Ok(new StateSyncResult { AppliedVersion = sync.Version })); @@ -356,28 +353,28 @@ public sealed class ProbeSimulator : IAsyncDisposable MoShiroMethods.TestPhase, MoShiroJsonContext.Default.TestPhaseStartParams, MoShiroJsonContext.Default.TestPhaseStartResult, - async (probe, start, ct) => Result.Ok(new TestPhaseStartResult { Accepted = await probe.StartPhaseAsync(start, ct) })) + async (dialer, start, ct) => Result.Ok(new TestPhaseStartResult { Accepted = await dialer.StartPhaseAsync(start, ct) })) .On( MoShiroMethods.TestPhaseEnd, MoShiroJsonContext.Default.TestPhaseEndParams, MoShiroJsonContext.Default.TestPhaseEndResult, - async (probe, end, ct) => Result.Ok(new TestPhaseEndResult { Fragments = await probe.EndPhaseAsync(end, ct) })) + async (dialer, end, ct) => Result.Ok(new TestPhaseEndResult { Fragments = await dialer.EndPhaseAsync(end, ct) })) .OnNotify( MoShiroMethods.TestAbort, MoShiroJsonContext.Default.TestAbortParams, - (probe, abort, _) => + (dialer, abort, _) => { - probe.Aborts.Enqueue(abort.TestId); + dialer.Aborts.Enqueue(abort.TestId); return ValueTask.CompletedTask; }) .OnNotify( FleetMethods.OutboxAck, FleetRpcContext.Default.OutboxAckParams, - (probe, ack, _) => + (dialer, ack, _) => { foreach (string id in ack.Ids.Concat(ack.Duplicates)) { - if (probe._acks.TryRemove(Guid.Parse(id), out TaskCompletionSource? waiter)) + if (dialer._acks.TryRemove(Guid.Parse(id), out TaskCompletionSource? waiter)) { waiter.TrySetResult(); } @@ -437,7 +434,7 @@ public sealed class ProbeSimulator : IAsyncDisposable for (uint from = 0; from < total || fragments == 0; from += PerFragment) { uint to = (uint)Math.Min((long)from + PerFragment, total); - var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsProbe("t_recv", nullable: true)])); + var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsDialer("t_recv", nullable: true)])); foreach ((uint seq, long recv) in train.Received.Where(p => p.Key >= from && p.Key < to).OrderBy(p => p.Key)) { pkt.Add(seq, recv);