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 数据通道状态:控制层不在线时是