agent: unify listening and dialing with directed mutual measurements

Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
This commit is contained in:
justme committed 2026-10-21 11:55:36 +08:00
1 parent d5ebf29e57
commit ad5efc62ff
206 files changed
+4202 -4964

No files matched your search

+2 -5
View File
@@ -27,11 +27,8 @@
<Folder Name="/backend/agent/src/">
<Project Path="backend/agent/src/MoShiro.Agent/MoShiro.Agent.csproj" />
</Folder>
<Folder Name="/backend/probe/src/">
<Project Path="backend/probe/src/MoShiro.Probe/MoShiro.Probe.csproj" />
</Folder>
<Folder Name="/backend/probe/tests/">
<Project Path="backend/probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj" />
<Folder Name="/backend/agent/tests/">
<Project Path="backend/agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj" />
</Folder>
<Folder Name="/backend/src/">
<Project Path="backend/src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
+2 -5
View File
@@ -27,11 +27,8 @@
<Folder Name="/agent/src/">
<Project Path="agent/src/MoShiro.Agent/MoShiro.Agent.csproj" />
</Folder>
<Folder Name="/probe/src/">
<Project Path="probe/src/MoShiro.Probe/MoShiro.Probe.csproj" />
</Folder>
<Folder Name="/probe/tests/">
<Project Path="probe/tests/MoShiro.Probe.Tests/MoShiro.Probe.Tests.csproj" />
<Folder Name="/agent/tests/">
<Project Path="agent/tests/MoShiro.Agent.Tests/MoShiro.Agent.Tests.csproj" />
</Folder>
<Folder Name="/backend/src/">
<Project Path="src/MoShiro.Backend.Analysis/MoShiro.Backend.Analysis.csproj" />
+1 -1
View File
@@ -38,7 +38,7 @@ dotnet publish src/MoShiro.Backend -c Release -r <RID> -p:PublishAot=true
例如 `<RID>` 可为 `win-x64`、`linux-x64` 或 `linux-arm64`。NativeAOT 发布需要对应平台的 C/C++ 工具链。仓库根目录的 `scripts/publish.sh backend <RID>` 可构建单个平台的 Backend 可执行文件;容器发布由 Woodpecker tag 流水线完成,详见 [`../docs/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 镜像中。
## 本地运行
+11 -9
View File
@@ -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)。
+104 -51
View File
@@ -10,13 +10,17 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>Agent 的组装点:数据面、点对点监听、与 Backend 的链路都在这里接起来。没有全局状态,测试可以在同一进程里启动多个。</summary>
/// <summary>
/// 节点的组装点:与 Backend 的链路、监听侧(数据面和点对点监听,<c>listen.enabled</c> 为 false 时不启动)、拨号侧(Backend 下发的拨号目标)都在这里接起来。
/// 没有全局状态,测试可以在同一进程里启动多个。
/// </summary>
public sealed class AgentApp : IDisposable, IAsyncDisposable
{
public const string Version = MoShiro.Protocol.BuildInfo.Version;
private readonly AgentConfig _config;
private readonly bool _ipv6Supported;
private readonly bool _listening;
private readonly bool? _ipv6Supported;
private readonly KnockGate _knock;
private readonly TrustAnchor _trust;
private readonly RestartController _restart;
@@ -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;
/// <summary>对等面(Probe 连进来的会话)的路由器,测试用。</summary>
/// <summary>拨号侧(测试和状态查看用)。</summary>
public DialerHost Dialer => _dialer;
/// <summary>对等面(拨号方连进来的会话)的路由器,测试用。</summary>
public RpcRouter<PeerContext> PeerRouter => _hub.Router;
/// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary>
public bool IsPeerKnown(string probeId, string publicKeyBase64) =>
_tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
/// <summary>期望状态里是否已经有这个拨号方的公钥(测试用:握手会按它校验)。</summary>
public bool IsPeerKnown(string dialerId, string publicKeyBase64) =>
_tasks.LookupPeerKey(dialerId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
public TestRunner Tests => _tests;
@@ -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<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Agent);
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table);
HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null;
bool Allow(string method) => allowed is null || allowed.Contains(method);
@@ -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<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.ProbeId.Value) }));
async (_, request, _) => Result<TestRunResult>.Ok(new TestRunResult { Accepted = await _hub.DisconnectAsync(request.DialerId.Value) }));
}
if (Allow(MoShiroMethods.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>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync)], _outbox, _time, _log);
var link = new FleetLink(
options,
router,
[
new StateHandler<TaskSet>(TaskSet.StateType, MoShiroJsonContext.Default.TaskSet, _tasks.ApplyAsync),
new StateHandler<DialTargets>(DialTargets.StateType, MoShiroJsonContext.Default.DialTargets, (version, targets, _) =>
{
_dialer.Directory.Apply(targets);
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
}),
],
_outbox,
_time,
_log);
// 链路重新连上之后补报:在线的 Probe 都告诉 Backend(Backend 重启或链路抖动期间错过了 probe_connected)
// 链路重新连上之后补报:在线的拨号方都告诉 Backend(Backend 重启或链路抖动期间错过了 dialer_connected)
link.OnConnected = async (_, _) =>
{
foreach (ConnectedProbe probe in _hub.ConnectedProbes())
foreach (ConnectedDialer dialer in _hub.ConnectedDialers())
{
await _uplink.EmitEventAsync(AgentEventKinds.ProbeConnected, probe.Id.Value, probe);
await _uplink.EmitEventAsync(AgentEventKinds.DialerConnected, dialer.Id.Value, dialer);
}
};
return link;
@@ -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);
}
}
@@ -44,23 +44,20 @@ public sealed class FleetSection
public sealed class ListenSection
{
/// <summary>是否接受拨号方的连接。关闭时节点只拨出:不开任何端口,在 Backend 的目录里也不是拨号目标(家宽和 NAT 后的节点用这个配置)。</summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
[JsonPropertyName("bind")]
public string Bind { get; set; } = "::";
/// <summary>Probe 控制通道(TLS,点对点会话)。</summary>
/// <summary>拨号方 控制通道(TLS,点对点会话)。</summary>
[JsonPropertyName("control_port")]
public int ControlPort { get; set; } = 7443;
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [7443];
/// <summary>Legacy config key accepted while deployments migrate to QUIC Datagram.</summary>
[JsonPropertyName("udp_ports")]
public List<int>? LegacyUdpPorts { get; set; }
[JsonIgnore]
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
[JsonPropertyName("tcp_ports")]
public List<int> 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;
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// Probe 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
/// 拨号方 的列块到达之后:找到对应的流 → 与 Agent 在数据面记录的列合并 → 盖章(块头、时钟锚点)→ 编码 → 放进发件箱(通道 16)。
/// 找不到对应流的列块(任务已经被撤销、测试已经结束)被丢弃并计数;这只会发生在配置变更的瞬间。
/// </summary>
public sealed class ChunkAssembler
@@ -33,20 +33,20 @@ public sealed class ChunkAssembler
}
/// <summary>
/// 处理某个 Probe 交来的一个列块(已经解码)。返回是否被接受。
/// 块头里的 <c>probe_id</c> 以认证过的会话为准(Probe 自报的值不可信),别的 Probe 的流也不会被命中。
/// 处理某个拨号方交来的一个列块(已经解码)。返回是否被接受。
/// 块头里的 <c>dialer_id</c> 以认证过的会话为准(拨号方 自报的值不可信),别的拨号方的流也不会被命中。
/// </summary>
public bool Accept(RawChunk fragment, ProbeSession probe)
public bool Accept(RawChunk fragment, DialerSession dialer)
{
RawChunkHeader header = fragment.Header;
StreamInfo? stream = header switch
{
{ TestId: { } test, Phase: { } phase } => _streams.FindByTest(test, phase) is { } found && ReferenceEquals(found.Owner, probe) ? found : null,
{ TaskId: { } task } => _streams.FindByTask(task, probe),
{ TestId: { } test, Phase: { } phase } => _streams.FindByTest(test, phase) is { } found && ReferenceEquals(found.Owner, dialer) ? found : null,
{ TaskId: { } task } => _streams.FindByTask(task, dialer),
_ => null,
};
header.ProbeId = new ProbeId(probe.ProbeId);
header.IpVersion = probe.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
header.DialerId = new NodeId(dialer.DialerId);
header.IpVersion = dialer.IpVersion; // 地址族以认证过的控制会话为准,不接受列块自报值
KindSpec? spec = stream is null ? null : Kinds.Find(stream.Kind);
if (stream is null || spec is null || spec.Id != header.Kind)
{
@@ -34,19 +34,19 @@ public sealed class AgentCounters
public void FragmentDiscarded() => Interlocked.Increment(ref _fragmentsDiscarded);
}
public sealed record PeerContext(ProbeSession Session, MonoClock Clock);
public sealed record PeerContext(DialerSession Session, MonoClock Clock);
/// <summary>
/// 一个已经认证的 Probe 会话:数据面的 <c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、
/// 一个已经认证的拨号方会话:数据面的 <c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)、最近一次的 UDP 源地址、
/// 每个流的重放窗口。数据面的包不绑定五元组,NAT 映射变化之后自动跟随新的源地址。
/// </summary>
public sealed class ProbeSession : DataSession
public sealed class DialerSession : DataSession
{
private readonly ConcurrentDictionary<uint, ReplayWindow> _windows = new();
private readonly ConcurrentDictionary<ulong, byte> _nonces = new();
private SocketAddress? _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;
/// <summary>Probe 的程序版本(握手里自报,只用于展示)。</summary>
/// <summary>拨号方 的程序版本(握手里自报,只用于展示)。</summary>
public string? Version => Info.Version;
/// <summary>Probe 的远端地址(经过 NAT 之后的公网 IP)。</summary>
/// <summary>拨号方 的远端地址(经过 NAT 之后的公网 IP)。</summary>
public string? RemoteAddress => (Info.Remote as IPEndPoint)?.Address.ToString();
public long ConnectedAtUtcMs { get; init; }
@@ -133,59 +133,59 @@ public sealed class ProbeSession : DataSession
public void ForgetStream(uint streamId) => _windows.TryRemove(streamId, out _);
}
/// <summary>每个 Probe 的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
/// <summary>每个拨号方的 IPv4、IPv6 会话独立;重连只替换同地址族的旧会话。</summary>
public sealed class SessionRegistry : IDataSessions
{
private readonly object _gate = new();
private readonly Dictionary<string, ProbeSession> _byProbe = new(StringComparer.Ordinal);
private readonly Dictionary<long, ProbeSession> _bySession = [];
private readonly Dictionary<string, DialerSession> _byDialer = new(StringComparer.Ordinal);
private readonly Dictionary<long, DialerSession> _bySession = [];
public IReadOnlyList<ProbeSession> All
public IReadOnlyList<DialerSession> All
{
get
{
lock (_gate)
{
return [.. _byProbe.Values];
return [.. _byDialer.Values];
}
}
}
public ProbeSession? Get(string probeId, string ipVersion = IpVersions.V4)
public DialerSession? Get(string dialerId, string ipVersion = IpVersions.V4)
{
lock (_gate)
{
return _byProbe.GetValueOrDefault(probeId + "~" + ipVersion);
return _byDialer.GetValueOrDefault(dialerId + "~" + ipVersion);
}
}
/// <summary>登记新会话;返回同一个 Probe 的旧会话(如果有)。</summary>
public ProbeSession? Replace(ProbeSession session)
/// <summary>登记新会话;返回同一个拨号方的旧会话(如果有)。</summary>
public DialerSession? Replace(DialerSession session)
{
lock (_gate)
{
_byProbe.TryGetValue(session.PathKey, out ProbeSession? old);
_byDialer.TryGetValue(session.PathKey, out DialerSession? old);
if (old is not null)
{
_bySession.Remove(old.SessionId);
}
_byProbe[session.PathKey] = session;
_byDialer[session.PathKey] = session;
_bySession[session.SessionId] = session;
return old;
}
}
public void Remove(ProbeSession session)
public void Remove(DialerSession session)
{
lock (_gate)
{
if (_byProbe.TryGetValue(session.PathKey, out ProbeSession? current) && ReferenceEquals(current, session))
if (_byDialer.TryGetValue(session.PathKey, out DialerSession? current) && ReferenceEquals(current, session))
{
_byProbe.Remove(session.PathKey);
_byDialer.Remove(session.PathKey);
}
if (_bySession.TryGetValue(session.SessionId, out ProbeSession? bySession) && ReferenceEquals(bySession, session))
if (_bySession.TryGetValue(session.SessionId, out DialerSession? bySession) && ReferenceEquals(bySession, session))
{
_bySession.Remove(session.SessionId);
}
@@ -236,8 +236,8 @@ public sealed class StreamInfo
public int? Phase { get; init; }
/// <summary>这个流属于哪个 Probe 会话:别的会话的包即使带着这个流编号也不会被接受。</summary>
public ProbeSession? Owner { get; init; }
/// <summary>这个流属于哪个拨号方会话:别的会话的包即使带着这个流编号也不会被接受。</summary>
public DialerSession? Owner { get; init; }
public int Port { get; }
@@ -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
}
/// <summary>NAT 映射变化,源地址变了:上报 <c>endpoint_changed</c>。</summary>
public Action<ProbeSession>? OnEndpointChanged { get; set; }
public Action<DialerSession>? 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;
@@ -1,7 +1,7 @@
namespace MoShiro.Probe;
namespace MoShiro.Agent;
/// <summary>Reconnect cadence for a single Probe-to-Agent path: 1, 2, 4, 8, 16, then five 32-second waits, then once per minute.</summary>
public sealed class ProbeReconnectBackoff
/// <summary>Reconnect cadence for the control connection of a single dialer-to-listener path: 1, 2, 4, 8, 16, then five 32-second waits, then once per minute.</summary>
public sealed class ControlBackoff
{
private static readonly TimeSpan[] s_delays =
[
@@ -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;
/// <summary>所有连接共用的东西。</summary>
public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
/// <summary>
/// 与一个 Agent 的点对点连接,分两层:
/// <list type="bullet">
/// <item>控制层:TCP+TLS 长连接。认证握手 → 同步时钟 → 按 Agent 下发的 <c>line_task_set</c> 运行积木 → 每 10 秒把已完成的行切成列块放进发件箱,
/// 发件箱和控制 RPC 共用这条连接。控制层断开才算与 Agent 失联:积木停止(数据面凭据属于控制会话),按 <see cref="ControlBackoff"/> 重拨。</item>
/// <item>任务层:TCP 类积木各自拨号;QUIC 数据通道由 <see cref="TaskLink"/> 在控制层在线期间单独维护,断开只重拨 QUIC。</item>
/// </list>
/// 发件箱属于这条连接,不属于某次控制会话:控制层重连之后重发还没确认的数据块。状态与日志由 <see cref="LinkStatus"/> 统一维护。
/// </summary>
public sealed class DialConnection : IDisposable
{
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
/// <summary>Agent 的任何帧(时钟回复、任务集)都算存活;比时钟同步间隔长得多,容得下差线路上的黑洞,但不会让一个 Agent 已经丢掉的会话永远挂着。</summary>
private static readonly TimeSpan s_idleLimit = TimeSpan.FromSeconds(120);
private static readonly RpcRouter<DialSession> s_router = BuildRouter();
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
public static RpcRouter<DialSession> Router => s_router;
private readonly DialEnv _env;
private readonly LinkStatus _status;
private readonly Outbox _outbox = new(8 << 20);
private readonly SemaphoreSlim _retrySignal = new(0, 1);
private INodeLog _attemptLog;
private string _phase = "idle";
private long _attemptStarted;
private volatile DialSession? _session;
private volatile TaskLink? _task;
private QuicDatagramTransport? _activeDatagrams;
public DialConnection(DialEndpoint endpoint, DialEnv env)
{
Endpoint = endpoint;
_env = env;
_attemptLog = env.Log;
_status = new LinkStatus(endpoint.NodeId, endpoint.IpVersion, env.Time, env.Log);
}
public DialEndpoint Endpoint { get; }
/// <summary>控制层在线,且 QUIC 数据通道可发送。</summary>
public bool IsConnected => _session is not null && Volatile.Read(ref _activeDatagrams)?.IsConnected == true;
public int RoleCount => _session?.RoleCount ?? 0;
public long ClockOffsetUs => _session?.Clock.OffsetUs ?? 0;
/// <summary>当前线路状态(随心跳上报给面板)。</summary>
public LinkHealth Health() =>
_status.Snapshot(_outbox, _session?.TcpBlockCount ?? 0, Volatile.Read(ref _activeDatagrams)?.GetTransportSnapshot());
/// <summary>管理端手动配对:控制层在退避就马上再拨;控制层在线而 QUIC 在重拨,就跳过 QUIC 的等待。</summary>
public void RetryNow()
{
if (_status.Control == ControlState.Up)
{
if (_status.Quic == QuicState.Retrying)
{
_task?.RetryNow();
}
return;
}
try
{
_retrySignal.Release();
}
catch (SemaphoreFullException)
{
// 已经有一次手动重试排队。
}
catch (ObjectDisposedException)
{
// 对应线路正在被移除。
}
}
public void Dispose() => _retrySignal.Dispose();
/// <summary>控制层循环:拨号,会话结束后按退避重拨。</summary>
public async Task RunAsync(CancellationToken ct)
{
var backoff = new ControlBackoff();
while (!ct.IsCancellationRequested)
{
_attemptStarted = _env.Time.GetTimestamp();
_attemptLog = new ContextNodeLog(_env.Log, ("agent", Endpoint.NodeId), ("ip_version", Endpoint.IpVersion),
("attempt_id", Guid.NewGuid().ToString("N")));
_status.Log = _attemptLog;
_phase = "dns";
_status.SetControl(ControlState.Dialing, attempt: backoff.Attempts);
LinkFault fault;
try
{
fault = await RunOnceAsync(backoff, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
fault = LinkFaults.From(ex, _phase);
}
try
{
TimeSpan delay = backoff.Next();
_status.SetControl(ControlState.Backoff, fault, backoff.Attempts, delay);
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task delayTask = Task.Delay(delay, _env.Time, waitCts.Token);
Task retryTask = _retrySignal.WaitAsync(waitCts.Token);
Task completed = await Task.WhenAny(delayTask, retryTask);
await waitCts.CancelAsync();
if (completed == retryTask)
{
await retryTask;
_attemptLog.Info("manual link retry requested");
}
else
{
await delayTask;
try { await retryTask; }
catch (OperationCanceledException) { }
}
}
catch (OperationCanceledException)
{
return;
}
}
}
/// <summary>一次控制会话:建立 → 运行 → 结束。建立失败抛出异常;建立之后结束返回结束原因。</summary>
private async Task<LinkFault> RunOnceAsync(ControlBackoff backoff, CancellationToken ct)
{
_attemptLog.Info("agent connection attempt started", ("phase", _phase), ("host", Endpoint.Host),
("tcp_port", Endpoint.TcpPort), ("quic_ports", string.Join(',', Endpoint.QuicPorts)));
IPAddress address = await ResolveAsync(ct);
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.NodeId, AgentApp.Version, 8 << 20, _env.Time, _attemptLog, ct,
onPhase: OnPeerPhase);
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
var remote = (IPEndPoint)info.Remote!;
List<int> quicPorts = Endpoint.QuicPorts;
int quicPort = quicPorts.Count == 0 ? 7443 : quicPorts[0];
var datagrams = new QuicDatagramTransport(
data,
_env.Clock,
new DataPlaneCounters(),
_attemptLog,
new IPEndPoint(remote.Address, quicPort),
agentKey);
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<DialSession>(transport, s_router, session, _env.Time);
rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
Task run = rpc.RunAsync(cts.Token);
Task pump = PumpAsync(rpc, _outbox, cts.Token);
Task quic = Task.CompletedTask;
Task maintenance = Task.CompletedTask;
TaskLink? taskLink = null;
try
{
// 先同步时钟(块头里要带偏移);Agent 随后推送线路任务集
_phase = "clock_sync";
_status.SetControl(ControlState.Syncing);
await SyncClockAsync(rpc, clock, rounds: 5, cts.Token);
_attemptLog.Info("agent clock synchronized", ("phase", _phase), ("offset_us", clock.OffsetUs));
backoff.Reset();
_retrySignal.Wait(0, CancellationToken.None); // 若“立即重试”恰好赶在本次拨号成功前到达,本次成功已满足请求。
_session = session;
_phase = "control_session";
_status.SetControl(ControlState.Up);
taskLink = new TaskLink(datagrams, _status, _env.Time);
_task = taskLink;
quic = taskLink.RunAsync(cts.Token);
maintenance = MaintainAsync(rpc, session, clock, cts.Token);
_attemptLog.Info("agent control session up", ("phase", _phase), ("remote", remote), ("quic_port", quicPort), ("offset_us", clock.OffsetUs));
Task finished = await Task.WhenAny(run, pump, maintenance, quic);
LinkFault fault = finished.IsFaulted && finished.Exception?.GetBaseException() is { } error
? LinkFaults.From(error, _phase)
: LinkFaults.Closed(_phase, finished == run
? "closed by the Agent control connection"
: finished == pump
? "outbox pump stopped"
: finished == quic
? "QUIC link loop stopped"
: "connection maintenance stopped");
await cts.CancelAsync();
await BoundedAsync("stop tasks", () => Task.WhenAll(Quiet(run), Quiet(pump), Quiet(maintenance), Quiet(quic)));
return fault;
}
finally
{
_task = null;
_session = null;
await cts.CancelAsync();
session.FlushLineBlocks(); // 停止前把已稳定的行交给发件箱,重连之后补传
await BoundedAsync("stop blocks", session.StopAllAsync);
await BoundedAsync("wait control", () => Quiet(run));
await BoundedAsync("wait pump", () => Quiet(pump));
await BoundedAsync("wait quic", () => Quiet(quic));
taskLink?.Dispose();
await BoundedAsync("dispose control", () => rpc.DisposeAsync().AsTask());
Volatile.Write(ref _activeDatagrams, null);
await BoundedAsync("dispose quic", () => datagrams.DisposeAsync().AsTask());
_status.SetQuic(QuicState.Idle);
}
}
private async Task<IPAddress> ResolveAsync(CancellationToken ct)
{
AddressFamily family = Endpoint.IpVersion == IpVersions.V6 ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
using var resolveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), _env.Time);
using CancellationTokenSource resolveCt = CancellationTokenSource.CreateLinkedTokenSource(ct, resolveTimeout.Token);
IPAddress[] addresses = IPAddress.TryParse(Endpoint.Host, out IPAddress? literal)
? [literal]
: await Dns.GetHostAddressesAsync(Endpoint.Host, family, resolveCt.Token);
IPAddress address = addresses.FirstOrDefault(a => a.AddressFamily == family && !a.IsIPv4MappedToIPv6)
?? throw new SocketException((int)SocketError.HostNotFound);
_attemptLog.Info("agent address resolved", ("phase", _phase), ("remote", address),
("elapsed_ms", (long)_env.Time.GetElapsedTime(_attemptStarted).TotalMilliseconds));
return address;
}
private void OnPeerPhase(string phase)
{
_phase = phase;
if (phase == "peer_auth")
{
_status.SetControl(ControlState.Authenticating);
}
}
private async Task BoundedAsync(string step, Func<Task> work)
{
if (!await Bounded.WaitAsync(work(), Bounded.Teardown, _env.Time))
{
_attemptLog.Warn("agent session teardown step timed out, moving on", ("phase", _phase), ("step", step), ("timeout_ms", Bounded.Teardown.TotalMilliseconds));
}
}
private static async Task Quiet(Task task)
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException or TimeoutException)
{
// 会话结束时的正常噪声
}
}
/// <summary>回传数据:发件箱里的块经控制连接发给 Agent。</summary>
private static async Task PumpAsync(RpcSession<DialSession> rpc, Outbox outbox, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[] frame = await outbox.TakeNextAsync(2 << 20, ct);
await rpc.SendBinaryAsync(frame, ct);
}
}
private async Task SyncClockAsync(RpcSession<DialSession> rpc, ClockEstimator clock, int rounds, CancellationToken ct)
{
for (int i = 0; i < rounds; i++)
{
long sent = _env.Clock.NowUs();
Result<ClockResult> reply = await rpc.RequestAsync(MoShiroMethods.Clock, new ClockParams { DialerMonoUs = sent }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, ct);
if (reply.TryGet(out ClockResult? value, out _))
{
clock.Add(sent, value.ListenerMonoUs, _env.Clock.NowUs());
}
}
}
/// <summary>切块(每 10 秒)、时钟同步(每 30 秒)和控制连接的存活检查。</summary>
private async Task MaintainAsync(RpcSession<DialSession> rpc, DialSession session, ClockEstimator clock, CancellationToken ct)
{
using var flush = new PeriodicTimer(s_flushInterval, _env.Time);
long lastClockUs = _env.Clock.NowUs();
while (await flush.WaitForNextTickAsync(ct))
{
if (rpc.IdleFor > s_idleLimit)
{
// 监听方已经不认这个会话(它重启、重连、或者空闲超时关了会话,而 FIN 没有到达):拨号方这边结束它,让外层按退避重拨
throw new ControlSilentException($"the Agent sent nothing on this session for {(long)rpc.IdleFor.TotalSeconds}s");
}
session.FlushLineBlocks();
if (_env.Clock.NowUs() - lastClockUs >= s_clockInterval.TotalMicroseconds)
{
lastClockUs = _env.Clock.NowUs();
await SyncClockAsync(rpc, clock, rounds: 1, ct);
}
}
}
private static RpcRouter<DialSession> BuildRouter() =>
new RpcRouter<DialSession>(RpcSide.Dialer, MoShiroMethodTable.Table)
.On(
FleetMethods.StateSync,
FleetRpcContext.Default.StateSyncParams,
FleetRpcContext.Default.StateSyncResult,
async (session, sync, _) =>
{
var result = new StateSyncResult { AppliedVersion = sync.Version };
if (sync.Type == LineTaskSet.StateType)
{
LineTaskSet? set = sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet);
if (set is null)
{
return Result<StateSyncResult>.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed");
}
result.Rejected.AddRange(await session.ApplyAsync(set));
}
return Result<StateSyncResult>.Ok(result);
})
.On(
MoShiroMethods.TestPhase,
MoShiroJsonContext.Default.TestPhaseStartParams,
MoShiroJsonContext.Default.TestPhaseStartResult,
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
.On(
MoShiroMethods.TestPhaseEnd,
MoShiroJsonContext.Default.TestPhaseEndParams,
MoShiroJsonContext.Default.TestPhaseEndResult,
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
.OnNotify(
MoShiroMethods.TestAbort,
MoShiroJsonContext.Default.TestAbortParams,
async (session, abort, _) => await session.AbortAsync(abort.TestId))
.OnNotify(
FleetMethods.OutboxAck,
FleetRpcContext.Default.OutboxAckParams,
(session, ack, _) =>
{
session.Outbox.Acknowledge(ack.Ids.Concat(ack.Duplicates).Select(Guid.Parse));
return ValueTask.CompletedTask;
});
}
@@ -0,0 +1,116 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Hosting;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 本节点要拨的监听节点列表:由面板作为期望状态(<c>dial_targets</c>)直接下发;面板连不上时用上一次保存的 <c>dial_targets.json</c>。
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
/// </summary>
public sealed class DialDirectory
{
public const string FileName = "dial_targets.json";
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
private readonly TrustAnchor _trust;
private List<DialEndpoint> _targets = [];
private long _version;
public DialDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_store = store;
_log = log;
string cache = Path.Combine(configDirectory, FileName);
if (File.Exists(cache))
{
try
{
DialCache saved = NodeConfig.Load(cache, AgentConfigContext.Default.DialCache);
_targets = saved.Targets;
_version = saved.Version;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException)
{
_log.Warn("dial_targets.json is unreadable, waiting for the panel", ("err", ex.Message));
}
}
}
/// <summary>列表变化(调和连接)。</summary>
public event Action? Changed;
public long Version
{
get
{
lock (_gate)
{
return _version;
}
}
}
public IReadOnlyList<DialEndpoint> Targets
{
get
{
lock (_gate)
{
return [.. _targets];
}
}
}
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
public void Apply(DialTargets result)
{
var next = new List<DialEndpoint>();
_trust.Update(result.TrustChain);
foreach (DialTarget target in result.Targets)
{
if (!string.IsNullOrWhiteSpace(target.Host))
{
next.Add(new DialEndpoint { NodeId = target.NodeId.Value, Host = target.Host, TcpPort = target.TcpPort, QuicPorts = [.. target.QuicPorts], PublicKey = target.PublicKey });
}
if (!string.IsNullOrWhiteSpace(target.Ipv6Host))
{
next.Add(new DialEndpoint { NodeId = target.NodeId.Value, Host = target.Ipv6Host, TcpPort = target.TcpPort, QuicPorts = [.. target.QuicPorts], PublicKey = target.PublicKey, IpVersion = IpVersions.V6 });
}
}
lock (_gate)
{
if (result.Version < _version || (result.Version == _version && Same(next, _targets)))
{
return;
}
_version = result.Version;
_targets = next;
}
try
{
_store.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new DialCache { Version = result.Version, Targets = next }, AgentConfigContext.Default.DialCache));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not save dial_targets.json", ("err", ex.Message));
}
_log.Info("dial targets updated", ("version", result.Version), ("targets", next.Count));
Changed?.Invoke();
}
private static bool Same(List<DialEndpoint> a, List<DialEndpoint> b) =>
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(DialEndpoint a, DialEndpoint b) =>
a.NodeId == b.NodeId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey;
}
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>要拨的一个监听节点的一条路径(一个 IP 族):地址、端口和固定证书用的公钥。</summary>
public sealed class DialEndpoint
{
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonIgnore]
public string PathKey => NodeId + "~" + IpVersion;
[JsonPropertyName("node_id")]
public string NodeId { get; set; } = "";
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [7443];
/// <summary>监听方的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
}
/// <summary><c>dial_targets.json</c>:最近一次从面板同步到的拨号目标(配置,不是测量数据);面板连不上时节点靠它继续测量。</summary>
public sealed class DialCache
{
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("targets")]
public List<DialEndpoint> Targets { get; set; } = [];
}
@@ -0,0 +1,205 @@
using System.Collections.Concurrent;
using Fleet.Link;
using Fleet.Rpc;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。</summary>
public sealed class DialSession
{
private const int MaxBlocks = 16;
private readonly object _gate = new();
private readonly Dictionary<string, RunningBlock> _lineBlocks = [];
private readonly Dictionary<string, string> _lineKinds = [];
private readonly ConcurrentDictionary<uint, PhaseState> _phases = new();
public DialSession(BlockEnv blockEnv, ClockEstimator clock, Outbox outbox, DialEnv env)
{
BlockEnv = blockEnv;
Clock = clock;
Outbox = outbox;
Env = env;
}
public BlockEnv BlockEnv { get; }
public ClockEstimator Clock { get; }
public Outbox Outbox { get; }
public DialEnv Env { get; }
public int RoleCount
{
get
{
lock (_gate)
{
return _lineBlocks.Count;
}
}
}
/// <summary>正在运行的 TCP 类线路积木数(它们自带套接字和重拨,不依赖 QUIC 数据通道)。</summary>
public int TcpBlockCount
{
get
{
lock (_gate)
{
return _lineKinds.Values.Count(IsTcpKind);
}
}
}
private static bool IsTcpKind(string kind) => kind is "tcp_echo" or "connect" or "hold" or "tcp_bulk";
/// <summary>调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。</summary>
public async Task<List<RejectedItem>> ApplyAsync(LineTaskSet set)
{
var rejected = new List<RejectedItem>();
var wanted = new Dictionary<string, BlockSpec>();
foreach (LineTask task in set.Tasks)
{
BlockSpec? spec = BlockCatalog.DialerKinds.Contains(task.Kind)
? BlockSpec.Create(task.Kind, task.Params, task.StreamId, task.Port, new FragmentScope(task.Id, null, null))
: null;
if (spec is null)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = BlockCatalog.DialerKinds.Contains(task.Kind) ? "invalid_params" : "unsupported" });
}
else if (wanted.Count >= MaxBlocks)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = "budget_exceeded" });
}
else
{
wanted[task.Id.Value] = spec;
}
}
var stopped = new List<RunningBlock>();
lock (_gate)
{
foreach ((string id, RunningBlock running) in _lineBlocks.ToList())
{
if (!wanted.TryGetValue(id, out BlockSpec? spec) || spec.Signature != running.Signature)
{
stopped.Add(running);
_lineBlocks.Remove(id);
_lineKinds.Remove(id);
}
}
foreach ((string id, BlockSpec spec) in wanted)
{
if (!_lineBlocks.ContainsKey(id))
{
_lineBlocks[id] = RunningBlock.Start(BlockCatalog.Find(spec.KindId)!.Dialer(BlockEnv, spec), spec.Signature);
_lineKinds[id] = spec.KindId;
}
}
}
// 被停止的积木把已经稳定的行交出去(刚发出、还在等回复的行不交:它们不是丢失,只是没有等到)
foreach (RunningBlock running in stopped)
{
await running.StopAsync();
running.Block.Flush(FlushMode.Settled);
}
return rejected;
}
public void FlushLineBlocks()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
}
foreach (RunningBlock running in blocks)
{
running.Block.Flush(FlushMode.Settled);
}
}
/// <summary>按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。</summary>
public bool StartPhase(TestPhaseStartParams start)
{
string? direction = start.Params.TryGetValue("direction", out System.Text.Json.JsonElement d) ? d.GetString() : null;
if (_phases.ContainsKey(start.StreamId) || !BlockCatalog.DialerKinds.Contains(start.Kind) || BlockCatalog.Find(start.Kind)!.Supports(direction) is false)
{
return false;
}
if (start.Kind == "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;
}
/// <summary>阶段结束:收尾、交出列块,返回块数。</summary>
public async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
{
if (!_phases.TryRemove(end.StreamId, out PhaseState? phase))
{
return 0;
}
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "udp_train" ? 200 : 300), BlockEnv.Time, ct);
await phase.Running.StopAsync();
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
}
public async Task AbortAsync(TestId test)
{
foreach ((uint stream, PhaseState phase) in _phases.ToList())
{
if (phase.Test == test && _phases.TryRemove(stream, out _))
{
await phase.Running.StopAsync();
}
}
}
public async Task StopAllAsync()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
_lineBlocks.Clear();
_lineKinds.Clear();
}
blocks.AddRange(_phases.Values.Select(p => p.Running));
_phases.Clear();
foreach (RunningBlock running in blocks)
{
await running.StopAsync();
}
}
private sealed record PhaseState(TestId Test, string Kind, RunningBlock Running);
}
@@ -0,0 +1,203 @@
using System.Collections.Concurrent;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 拨号侧的组装点:每个拨号目标(监听节点 × IP 族)一个 <see cref="DialConnection"/>。目标列表变化时只调和连接:
/// 新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
/// </summary>
public sealed class DialerHost : IDisposable
{
private readonly DialEnv _env;
private readonly Dictionary<string, Running> _connections = [];
private readonly ConcurrentDictionary<string, long> _pendingRetries = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _changed = new(0);
private readonly TimeProvider _time;
private readonly INodeLog _log;
public DialerHost(DialEnv env, TimeProvider time, INodeLog log)
{
_env = env;
_time = time;
_log = log;
env.Directory.Changed += () => _changed.Release();
}
public DialDirectory Directory => _env.Directory;
/// <summary>某条路径的控制会话是否已经建立、QUIC 数据通道是否可发送(测试和状态查看用)。</summary>
public bool IsConnected(string listenerId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(listenerId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected;
}
}
public DialConnection? Connection(string listenerId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(listenerId + "~" + ipVersion, out Running? running) ? running.Connection : null;
}
}
/// <summary>每条路径当前的线路健康(随心跳上报)。</summary>
public List<LinkHealth> SnapshotLinks()
{
DialConnection[] connections;
lock (_connections)
{
connections = [.. _connections.Values.Select(r => r.Connection)];
}
return [.. connections.Select(c => c.Health())];
}
/// <summary>管理端手动重试:跳过这个监听节点所有路径的退避。目标列表还没到时,30 秒内到达就补上。</summary>
public void RetryListener(string listenerId, bool queueIfMissing = true)
{
DialConnection[] matches;
lock (_connections)
{
matches = [.. _connections.Values.Select(r => r.Connection).Where(c => c.Endpoint.NodeId == listenerId)];
}
if (matches.Length == 0)
{
if (queueIfMissing)
{
_pendingRetries[listenerId] = _time.GetUtcNow().AddSeconds(30).ToUnixTimeMilliseconds();
}
_log.Warn("manual link retry requested but no matching dial target is configured", ("listener", listenerId));
return;
}
_pendingRetries.TryRemove(listenerId, out _);
foreach (DialConnection connection in matches)
{
connection.RetryNow();
}
}
public async Task RunAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await ReconcileAsync(_env.Directory.Targets, ct);
RetryPending();
await _changed.WaitAsync(ct);
}
}
catch (OperationCanceledException)
{
// 停止
}
finally
{
await ReconcileAsync([], CancellationToken.None);
}
}
public void Dispose() => _changed.Dispose();
private void RetryPending()
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
foreach ((string listenerId, long expiresAt) in _pendingRetries.ToArray())
{
if (expiresAt <= now)
{
_pendingRetries.TryRemove(listenerId, out _);
}
else
{
RetryListener(listenerId, queueIfMissing: false);
}
}
}
private async Task ReconcileAsync(IReadOnlyList<DialEndpoint> targets, CancellationToken ct)
{
var wanted = targets.ToDictionary(t => t.PathKey);
var stopped = new List<Running>();
lock (_connections)
{
foreach ((string id, Running running) in _connections.ToList())
{
if (!wanted.TryGetValue(id, out DialEndpoint? endpoint) || !DialDirectory.Equal(endpoint, running.Connection.Endpoint))
{
stopped.Add(running);
_connections.Remove(id);
}
}
}
foreach (Running running in stopped)
{
await running.StopAsync();
}
if (ct.IsCancellationRequested)
{
return;
}
lock (_connections)
{
foreach ((string id, DialEndpoint endpoint) in wanted)
{
if (!_connections.ContainsKey(id))
{
_connections[id] = Running.Start(new DialConnection(endpoint, _env));
}
}
}
}
private sealed class Running : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task _run = Task.CompletedTask;
private Running(DialConnection connection)
{
Connection = connection;
}
public DialConnection Connection { get; }
public static Running Start(DialConnection connection)
{
var running = new Running(connection);
running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 没有及时退出:不再等
}
Dispose();
}
public void Dispose()
{
Connection.Dispose();
_cts.Dispose();
}
}
}
@@ -0,0 +1,44 @@
using System.Net.Sockets;
using System.Security.Authentication;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>控制连接在已建立之后停止应答:对端不认这个会话了,或者线路中断超过了空闲上限。</summary>
public sealed class ControlSilentException(string message) : IOException(message);
/// <summary>把连接各阶段的异常归类成上报给面板的 <see cref="LinkFault"/>:失败发生在哪个阶段、属于哪一类。</summary>
public static class LinkFaults
{
private const int MaxMessage = 200;
public static LinkFault From(Exception ex, string phase)
{
Exception inner = ex is AggregateException { InnerExceptions.Count: 1 } aggregate ? aggregate.InnerExceptions[0] : ex;
string kind = inner switch
{
ControlSilentException => "silent",
OperationCanceledException or TimeoutException => "timeout",
AuthenticationException => "tls",
SocketException socket => Kind(socket.SocketErrorCode),
IOException { InnerException: SocketException socket } => Kind(socket.SocketErrorCode),
InvalidOperationException invalid when invalid.Message.Contains("authentication", StringComparison.OrdinalIgnoreCase)
|| invalid.Message.Contains("challenge", StringComparison.OrdinalIgnoreCase) => "auth",
IOException => "closed",
_ => "other",
};
string message = kind == "timeout" && inner is OperationCanceledException ? $"{phase} did not finish in time" : inner.Message;
return new LinkFault { Phase = phase, Kind = kind, Message = message.Length > MaxMessage ? message[..MaxMessage] : message };
}
public static LinkFault Closed(string phase, string message) => new() { Phase = phase, Kind = "closed", Message = message };
private static string Kind(SocketError error) => error switch
{
SocketError.ConnectionRefused => "refused",
SocketError.ConnectionReset or SocketError.ConnectionAborted => "reset",
SocketError.HostNotFound or SocketError.NoData or SocketError.TryAgain => "dns",
SocketError.TimedOut => "timeout",
_ => "other",
};
}
@@ -0,0 +1,247 @@
using Fleet.Core;
using Fleet.Link;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 一条线路(本节点拨号到一个监听节点的一个 IP 族)的状态:控制层和 QUIC 数据通道各一个状态机,状态迁移只在这里发生、只在这里记日志。
/// 控制层每次迁移写一条 <c>link state</c>;数据通道只在“可用 ↔ 不可用”的边界上写,重拨过程中的失败只计数。
/// </summary>
public sealed class LinkStatus
{
private const int MaxHandshakeSamples = 128;
private readonly object _gate = new();
private readonly string _listenerId;
private readonly string _ipVersion;
private readonly TimeProvider _time;
private readonly Queue<double> _handshakeMs = new();
private ControlState _control = ControlState.Dialing;
private long _controlSince;
private LinkFault? _controlFault;
private int _controlAttempt;
private long? _controlRetryAt;
private QuicState _quic = QuicState.Idle;
private long _quicSince;
private long? _quicDownSince;
private LinkFault? _quicFault;
private int _quicAttempt;
private long? _quicRetryAt;
private long _handshakeAttempts;
private long _handshakeSuccesses;
private long _handshakeFailures;
private long _handshakeTimeouts;
public LinkStatus(string listenerId, string ipVersion, TimeProvider time, INodeLog log)
{
_listenerId = listenerId;
_ipVersion = ipVersion;
_time = time;
Log = log;
_controlSince = _quicSince = Now();
}
/// <summary>当前这次尝试的日志(带 attempt_id 等上下文);控制层每次重拨前由连接更新。</summary>
public INodeLog Log { get; set; }
public ControlState Control
{
get
{
lock (_gate)
{
return _control;
}
}
}
public QuicState Quic
{
get
{
lock (_gate)
{
return _quic;
}
}
}
public bool Ready => Control == ControlState.Up && Quic == QuicState.Ready;
public void SetControl(ControlState state, LinkFault? fault = null, int attempt = 0, TimeSpan? retryIn = null)
{
ControlState from;
long inStateMs;
lock (_gate)
{
from = _control;
long now = Now();
inStateMs = now - _controlSince;
if (state != from)
{
_controlSince = now;
}
_control = state;
_controlFault = state == ControlState.Up ? null : fault ?? _controlFault;
_controlAttempt = attempt;
_controlRetryAt = retryIn is { } delay ? now + (long)delay.TotalMilliseconds : null;
}
if (state == from)
{
return;
}
string reason = state == ControlState.Backoff && fault is not null ? fault.ToString() : "";
Log.Write(state == ControlState.Backoff ? NodeLogLevel.Warn : NodeLogLevel.Info, "link state",
[
("layer", "control"), ("listener", _listenerId), ("ip_version", _ipVersion), ("from", Name(from)), ("to", Name(state)),
("in_state_ms", inStateMs), ("attempt", attempt), ("retry_in_ms", retryIn is { } r ? (long)r.TotalMilliseconds : null),
("reason", reason.Length == 0 ? null : reason),
]);
}
public void SetQuic(QuicState state, LinkFault? fault = null, int attempt = 0, TimeSpan? retryIn = null)
{
QuicState from;
long? downMs = null;
bool logged;
lock (_gate)
{
from = _quic;
long now = Now();
if (state != from)
{
_quicSince = now;
}
if (state == QuicState.Ready)
{
downMs = _quicDownSince is { } down ? now - down : null;
_quicDownSince = null;
_quicFault = null;
}
else if (state is QuicState.Retrying or QuicState.Connecting)
{
_quicDownSince ??= now;
_quicFault = fault ?? _quicFault;
}
else
{
_quicDownSince = null;
}
// 数据通道抖动时不逐次打印:恢复可用、首次失败时各一条,重拨中的失败只计数。
logged = state == QuicState.Ready || (state == QuicState.Retrying && (from == QuicState.Ready || attempt == 1));
_quic = state;
_quicAttempt = attempt;
_quicRetryAt = retryIn is { } delay ? now + (long)delay.TotalMilliseconds : null;
}
if (logged)
{
Log.Write(state == QuicState.Retrying ? NodeLogLevel.Warn : NodeLogLevel.Info, "link state",
[
("layer", "task_quic"), ("listener", _listenerId), ("ip_version", _ipVersion), ("from", Name(from)), ("to", Name(state)),
("down_ms", downMs), ("attempt", attempt), ("retry_in_ms", retryIn is { } r ? (long)r.TotalMilliseconds : null),
("reason", state == QuicState.Retrying ? fault?.ToString() : null),
]);
}
}
/// <summary>记录一次 QUIC 握手的结果。</summary>
public void Handshake(bool succeeded, double? elapsedMs, bool timedOut)
{
lock (_gate)
{
_handshakeAttempts++;
if (succeeded)
{
_handshakeSuccesses++;
if (elapsedMs is { } ms)
{
_handshakeMs.Enqueue(ms);
while (_handshakeMs.Count > MaxHandshakeSamples)
{
_handshakeMs.Dequeue();
}
}
}
else
{
_handshakeFailures++;
if (timedOut)
{
_handshakeTimeouts++;
}
}
}
}
public LinkHealth Snapshot(Outbox outbox, int tcpBlocks, QuicTransportSnapshot? transport)
{
lock (_gate)
{
long now = Now();
double? p95 = null;
if (_handshakeMs.Count > 0)
{
double[] sorted = [.. _handshakeMs.Order()];
p95 = sorted[(int)Math.Ceiling(sorted.Length * 0.95) - 1];
}
return new LinkHealth
{
ListenerId = _listenerId,
IpVersion = _ipVersion,
Control = new ControlHealth
{
State = _control,
SinceMs = _controlSince,
LastError = _controlFault,
RetryAttempt = _controlAttempt,
RetryInMs = _control == ControlState.Backoff ? RetryIn(_controlRetryAt, now) : null,
OutboxBytes = outbox.PendingBytes,
OutboxPending = outbox.PendingCount,
},
Task = new TaskHealth
{
TcpBlocks = tcpBlocks,
Quic = new QuicHealth
{
State = _quic,
SinceMs = _quicSince,
LastError = _quicFault,
RetryAttempt = _quicAttempt,
RetryInMs = _quic == QuicState.Retrying ? RetryIn(_quicRetryAt, now) : null,
HandshakeAttempts = _handshakeAttempts,
HandshakeSuccesses = _handshakeSuccesses,
HandshakeFailures = _handshakeFailures,
HandshakeTimeouts = _handshakeTimeouts,
HandshakeP95Ms = p95,
HandshakeSampleCount = _handshakeMs.Count,
TransportPacketLossRate = transport?.PacketLossRate,
TransportPacketsSent = transport?.PacketsSent,
TransportCongestionEvents = transport?.CongestionEvents,
TransportEcnCongestionEvents = transport?.EcnCongestionEvents,
PathMtu = transport?.PathMtu,
},
},
};
}
}
private static long? RetryIn(long? at, long now) => at is { } t ? Math.Max(0, t - now) : null;
private long Now() => _time.GetUtcNow().ToUnixTimeMilliseconds();
private static string Name(ControlState state) => state.ToString().ToLowerInvariant();
private static string Name(QuicState state) => state.ToString().ToLowerInvariant();
}
@@ -0,0 +1,23 @@
namespace MoShiro.Agent;
/// <summary>
/// QUIC 数据通道的重拨节奏:1、2、3、5 秒,之后固定 5 秒。数据通道断开期间的测量是缺口,所以不像控制连接那样拉长到一分钟。
/// 抖动由调用方通过 <paramref name="random"/> 给出,避免多条线路同时重拨。
/// </summary>
public sealed class QuicRedialBackoff
{
private static readonly TimeSpan[] s_delays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5)];
private 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;
}
@@ -4,11 +4,11 @@ using Fleet.Link;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
namespace MoShiro.Agent;
/// <summary>
/// 估计 Agent 单调时钟 − Probe 单调时钟:每次 <c>moshiro.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
/// (往返越短,对称性假设的误差越小)。Probe 的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
/// 估计监听方单调时钟 − 拨号方单调时钟:每次 <c>moshiro.clock</c> 往返取中点,在最近 8 次里取往返最短的一次
/// (往返越短,对称性假设的误差越小)。拨号方的列块头里带着这个偏移,Backend 据此对齐两端的时间戳。
/// </summary>
public sealed class ClockEstimator
{
@@ -21,11 +21,11 @@ public sealed class ClockEstimator
public bool HasEstimate { get; private set; }
public void Add(long sendUs, long agentUs, long receiveUs)
public void Add(long sendUs, long listenerUs, long receiveUs)
{
lock (_gate)
{
_samples.Enqueue((receiveUs - sendUs, agentUs - ((sendUs + receiveUs) / 2)));
_samples.Enqueue((receiveUs - sendUs, listenerUs - ((sendUs + receiveUs) / 2)));
while (_samples.Count > Window)
{
_samples.Dequeue();
@@ -38,22 +38,22 @@ public sealed class ClockEstimator
}
/// <summary>
/// 把 Probe 一侧的行交给这个 Agent:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。
/// 把拨号方一侧的行交给监听方:补全块头 → 编码 → 放进会话的发件箱(通道 17,至少一次,有界)。
/// 发件箱的内容在连接断开重连之后重发;发件箱满了丢弃最旧的。
/// </summary>
public sealed class FragmentShipper : IFragmentSink
{
private readonly Outbox _outbox;
private readonly NodeId _agent;
private readonly ProbeId _probe;
private readonly NodeId _listener;
private readonly NodeId _dialer;
private readonly ClockEstimator _clock;
private readonly TimeProvider _time;
public FragmentShipper(Outbox outbox, NodeId agent, ProbeId probe, ClockEstimator clock, TimeProvider time)
public FragmentShipper(Outbox outbox, NodeId listener, NodeId dialer, ClockEstimator clock, TimeProvider time)
{
_outbox = outbox;
_agent = agent;
_probe = probe;
_listener = listener;
_dialer = dialer;
_clock = clock;
_time = time;
}
@@ -67,12 +67,12 @@ public sealed class FragmentShipper : IFragmentSink
TaskId = scope.Task,
TestId = scope.Test,
Phase = scope.Phase,
AgentId = _agent,
ProbeId = _probe,
AgentId = _listener,
DialerId = _dialer,
Port = port,
StartUtcMs = startUtcMs,
DurationMs = Math.Max(1, durationMs),
Clock = new ChunkClock { ProbeOffsetUs = _clock.OffsetUs },
Clock = new ChunkClock { DialerOffsetUs = _clock.OffsetUs },
};
if (parameters is not null)
{
@@ -0,0 +1,162 @@
using Fleet.Core;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 任务层的 QUIC 数据通道:控制层在线期间在后台维持一条 QUIC 连接,断开后按 <see cref="QuicRedialBackoff"/> 重拨。
/// 重拨期间控制连接和 TCP 类积木不受影响;UDP 类积木的发送在通道不可用时按各自的规则处理。
/// </summary>
public sealed class TaskLink : IDisposable
{
/// <summary>QUIC 连接可用至少这么久才算稳定,重拨节奏随之复位。刚连上就断的通道不会把间隔重置回 1 秒。</summary>
private static readonly TimeSpan s_stableAfter = TimeSpan.FromSeconds(10);
/// <summary>一直在发测量报文、却这么久没有收到任何一个:QUIC 路径已经是黑洞,主动关闭重拨。</summary>
private static readonly TimeSpan s_silentLimit = TimeSpan.FromSeconds(30);
private static readonly TimeSpan s_watchInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan s_summaryInterval = TimeSpan.FromSeconds(60);
private readonly QuicDatagramTransport _datagrams;
private readonly LinkStatus _status;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _retrySignal = new(0, 1);
public TaskLink(QuicDatagramTransport datagrams, LinkStatus status, TimeProvider time)
{
_datagrams = datagrams;
_status = status;
_time = time;
}
public void Dispose() => _retrySignal.Dispose();
/// <summary>管理端手动重试:跳过当前的重拨等待。</summary>
public void RetryNow()
{
try
{
_retrySignal.Release();
}
catch (SemaphoreFullException)
{
// 已有一次排队
}
}
public async Task RunAsync(CancellationToken ct)
{
var backoff = new QuicRedialBackoff();
while (!ct.IsCancellationRequested)
{
_status.SetQuic(QuicState.Connecting, attempt: backoff.Attempts);
LinkFault fault;
long readyAt = 0;
try
{
try
{
await _datagrams.ConnectAsync(ct);
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
_status.Handshake(false, null, ex is TimeoutException or OperationCanceledException);
throw;
}
_status.Handshake(true, _datagrams.HandshakeDurationMs, false);
readyAt = _time.GetTimestamp();
_status.SetQuic(QuicState.Ready);
fault = await WatchAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
fault = LinkFaults.From(ex, "quic_handshake");
}
await _datagrams.CloseClientAsync();
if (readyAt != 0 && _time.GetElapsedTime(readyAt) >= s_stableAfter)
{
backoff.Reset();
}
TimeSpan delay = backoff.Next(Random.Shared);
_status.SetQuic(QuicState.Retrying, fault, backoff.Attempts, delay);
try
{
using var wait = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task sleep = Task.Delay(delay, _time, wait.Token);
Task manual = _retrySignal.WaitAsync(wait.Token);
await Task.WhenAny(sleep, manual);
await wait.CancelAsync();
await Task.WhenAll(Settle(sleep), Settle(manual));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
}
}
/// <summary>连接可用期间的看护:连接关闭(对端关闭、空闲超时)或数据面静默时返回原因。</summary>
private async Task<LinkFault> WatchAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(s_watchInterval, _time);
Task closed = _datagrams.WaitForDisconnectAsync(ct);
long seenIn = _datagrams.Counters.PacketsIn;
long seenOut = _datagrams.Counters.PacketsOut;
long inChangedAt = _time.GetTimestamp();
long lastSummaryAt = inChangedAt;
while (true)
{
Task tick = timer.WaitForNextTickAsync(ct).AsTask();
if (await Task.WhenAny(closed, tick) == closed)
{
await closed;
return LinkFaults.Closed("quic_data", "QUIC connection closed");
}
await tick;
long packetsIn = _datagrams.Counters.PacketsIn;
long packetsOut = _datagrams.Counters.PacketsOut;
long now = _time.GetTimestamp();
if (packetsIn != seenIn)
{
seenIn = packetsIn;
inChangedAt = now;
}
else if (packetsOut != seenOut && _time.GetElapsedTime(inChangedAt, now) > s_silentLimit)
{
return new LinkFault { Phase = "quic_data", Kind = "silent", Message = $"no datagram received for {(long)_time.GetElapsedTime(inChangedAt, now).TotalSeconds}s while sending" };
}
seenOut = packetsOut;
if (_time.GetElapsedTime(lastSummaryAt, now) >= s_summaryInterval)
{
lastSummaryAt = now;
QuicTransportSnapshot? transport = _datagrams.GetTransportSnapshot();
_status.Log.Info("agent measurement summary", ("layer", "task_quic"), ("packets_in", packetsIn), ("packets_out", packetsOut),
("dropped_invalid", _datagrams.Counters.DroppedInvalid), ("dropped_rate_limited", _datagrams.Counters.DroppedRateLimited),
("transport_packets_sent", transport?.PacketsSent), ("path_mtu", transport?.PathMtu));
}
}
}
private static async Task Settle(Task task)
{
try
{
await task;
}
catch (OperationCanceledException)
{
// 等待被另一条路径或停止打断
}
}
}
@@ -5,7 +5,7 @@ using Fleet.Hosting;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
namespace MoShiro.Agent;
/// <summary>
/// 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;
+57 -57
View File
@@ -22,7 +22,7 @@ public sealed class BackendUplink
public bool IsConnected => _link?.CurrentSession is not null;
public async ValueTask EmitEventAsync(string kind, string? detail = null, ConnectedProbe? probe = null)
public async ValueTask EmitEventAsync(string kind, string? detail = null, ConnectedDialer? dialer = null)
{
RpcSession<LinkContext>? session = _link?.CurrentSession;
if (session is null)
@@ -30,7 +30,7 @@ public sealed class BackendUplink
return;
}
var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Probe = probe };
var parameters = new AgentEventParams { Kind = kind, AtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail, Dialer = dialer };
await SafeAsync(() => session.NotifyAsync(MoShiroMethods.Event, parameters, MoShiroJsonContext.Default.AgentEventParams, CancellationToken.None));
}
@@ -71,14 +71,14 @@ public sealed class BackendUplink
}
/// <summary>
/// Probe 的点对点会话:每次握手成功之后登记会话(同一个 Probe 的新会话取代它自己的旧会话,不同 Probe 的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收 Probe 的列块并交给
/// <see cref="ChunkAssembler"/>。会话结束时通知 Backend,并让正在运行的测试以 <c>probe_offline</c> 结束。
/// 拨号方 的点对点会话:每次握手成功之后登记会话(同一个拨号方的新会话取代它自己的旧会话,不同拨号方的会话并存)、为这个会话起一组线路任务的流并下发线路任务集、接收拨号方的列块并交给
/// <see cref="ChunkAssembler"/>。会话结束时通知 Backend,并让正在运行的测试以 <c>dialer_offline</c> 结束。
/// </summary>
public sealed class PeerHub
{
private const int DedupeWindow = 1024;
/// <summary>超过这么久没有收到 Probe 的任何帧就认为会话已死(Probe 每 30 秒至少发一次时钟同步)。
/// <summary>超过这么久没有收到拨号方的任何帧就认为会话已死(拨号方 每 30 秒至少发一次时钟同步)。
/// 要比线路可能的中断长得多:差的线路上几十秒甚至一两分钟的黑洞时有发生(TCP RTT 能到 30 秒),75 秒就关会话会把一次中断变成几分钟的数据空白(要重新握手,而握手在差线路上也慢)。</summary>
private static readonly TimeSpan IdleLimit = TimeSpan.FromSeconds(180);
@@ -106,15 +106,15 @@ public sealed class PeerHub
_log = log;
}
/// <summary>Probe 会话断开(这个 Probe 上的测试据此中止)。</summary>
public Action<ProbeSession>? OnProbeLost { get; set; }
/// <summary>拨号方 会话断开(这个拨号方上的测试据此中止)。</summary>
public Action<DialerSession>? OnDialerLost { get; set; }
public async Task OnSessionAsync(PeerSessionInfo info, IRpcTransport transport, CancellationToken ct)
{
var probe = new ProbeSession(info) { ConnectedAtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
var log = new ContextNodeLog(_log, ("probe", probe.ProbeId), ("ip_version", probe.IpVersion), ("control_id", Guid.NewGuid().ToString("N")));
var rpc = new RpcSession<PeerContext>(transport, _router, new PeerContext(probe, _clock), _time);
probe.Rpc = rpc;
var dialer = new DialerSession(info) { ConnectedAtUtcMs = _time.GetUtcNow().ToUnixTimeMilliseconds() };
var log = new ContextNodeLog(_log, ("dialer", dialer.DialerId), ("ip_version", dialer.IpVersion), ("control_id", Guid.NewGuid().ToString("N")));
var rpc = new RpcSession<PeerContext>(transport, _router, new PeerContext(dialer, _clock), _time);
dialer.Rpc = rpc;
var seen = new HashSet<Guid>();
var order = new Queue<Guid>();
@@ -137,7 +137,7 @@ public sealed class PeerHub
Result<RawChunk> decoded = RawChunkCodec.Decode(payload.Span);
if (decoded.TryGet(out RawChunk? fragment, out FleetError? error))
{
_chunks.Accept(fragment, probe);
_chunks.Accept(fragment, dialer);
}
else
{
@@ -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));
}
}
/// <summary>
/// Probe 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 <see cref="IdleLimit"/> 没有收到任何东西,
/// 说明 Probe 已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 probe_disconnected,而不是一直显示在线。
/// 拨号方 每 30 秒发一次时钟同步请求,所以会话里至少每 30 秒有一帧:超过 <see cref="IdleLimit"/> 没有收到任何东西,
/// 说明拨号方已经静默死亡(断电、进程被冻结),关闭会话,让 Backend 看到 dialer_disconnected,而不是一直显示在线。
/// </summary>
private async Task WatchIdleAsync(ProbeSession probe, RpcSession<PeerContext> rpc, INodeLog log, CancellationToken ct)
private async Task WatchIdleAsync(DialerSession dialer, RpcSession<PeerContext> rpc, INodeLog log, CancellationToken ct)
{
long lastSummary = _time.GetTimestamp();
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), _time);
@@ -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
}
}
/// <summary>当前连着的 Probe(心跳和 Backend 重连之后的补报都用它)。</summary>
public IReadOnlyList<ConnectedProbe> ConnectedProbes() => [.. _sessions.All.Select(Describe)];
/// <summary>当前连着的拨号方(心跳和 Backend 重连之后的补报都用它)。</summary>
public IReadOnlyList<ConnectedDialer> ConnectedDialers() => [.. _sessions.All.Select(Describe)];
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
private static ConnectedDialer Describe(DialerSession p) => new() { Id = new NodeId(p.DialerId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
/// <summary>向一个拨号方下发它的线路任务集(拨号方 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
public async ValueTask PushToAsync(DialerSession dialer, LineTaskSet set) => await PushAsync(dialer, set, CancellationToken.None);
/// <summary>Backend 要求断开某个 Probe(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
public async ValueTask<bool> DisconnectAsync(string probeId)
/// <summary>Backend 要求断开某个拨号方(它会按退避重连,除非已经被吊销)。没有这个会话时返回 false。</summary>
public async ValueTask<bool> DisconnectAsync(string dialerId)
{
bool disconnected = false;
foreach (ProbeSession probe in _sessions.All)
foreach (DialerSession dialer in _sessions.All)
{
if (probe.ProbeId == probeId && probe.Rpc is { } rpc)
if (dialer.DialerId == dialerId && dialer.Rpc is { } rpc)
{
probe.CloseReason = "disconnected";
dialer.CloseReason = "disconnected";
await rpc.CloseAsync("disconnected", CancellationToken.None);
disconnected = true;
}
@@ -255,23 +255,23 @@ public sealed class PeerHub
return disconnected;
}
/// <summary>名单(期望状态里的 peers)里已经没有的 Probe:立刻关闭它的会话(吊销生效)。</summary>
/// <summary>名单(期望状态里的 peers)里已经没有的拨号方:立刻关闭它的会话(吊销生效)。</summary>
public void CloseNotIn(IReadOnlySet<string> allowed)
{
foreach (ProbeSession probe in _sessions.All)
foreach (DialerSession dialer in _sessions.All)
{
if (!allowed.Contains(probe.ProbeId) && probe.Rpc is { } rpc)
if (!allowed.Contains(dialer.DialerId) && dialer.Rpc is { } rpc)
{
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<StateSyncResult> result = await probe.Rpc.RequestAsync(FleetMethods.StateSync, parameters, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, ct);
Result<StateSyncResult> result = await dialer.Rpc.RequestAsync(FleetMethods.StateSync, parameters, FleetRpcContext.Default.StateSyncParams, FleetRpcContext.Default.StateSyncResult, ct);
if (!result.IsSuccess)
{
_log.Warn("probe rejected the line task set", ("err", result.Error.Code));
_log.Warn("dialer rejected the line task set", ("err", result.Error.Code));
}
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException or IOException or OperationCanceledException)
@@ -302,5 +302,5 @@ public sealed class PeerHub
MoShiroMethods.Clock,
MoShiroJsonContext.Default.ClockParams,
MoShiroJsonContext.Default.ClockResult,
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { AgentMonoUs = context.Clock.NowUs() })));
(context, _, _) => ValueTask.FromResult(Result<ClockResult>.Ok(new ClockResult { ListenerMonoUs = context.Clock.NowUs() })));
}
@@ -127,7 +127,7 @@ public sealed class TargetRunners
return IPAddress.TryParse(target, out address!);
}
/// <summary>目标任务的行没有 Probe 一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
/// <summary>目标任务的行没有拨号方一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
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));
}
@@ -9,9 +9,9 @@ namespace MoShiro.Agent;
/// <summary>
/// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。
/// 通过的线路任务<b>为每个 Probe 会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个 Probe;
/// 通过的目标任务在本地运行,与 Probe 无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个 Probe 会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
/// 通过的线路任务<b>为每个拨号方会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个拨号方;
/// 通过的目标任务在本地运行,与拨号方无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个拨号方会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
/// </summary>
public sealed class TaskManager
{
@@ -27,15 +27,17 @@ public sealed class TaskManager
private readonly Func<IReadOnlyList<int>> _quicPorts;
private readonly Func<IReadOnlyList<int>> _tcpPorts;
private readonly TrustAnchor _trust;
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
private readonly bool _acceptLineTasks;
private readonly Dictionary<DialerSession, Dictionary<string, LineEntry>> _sessionLines = [];
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
private long _version;
private BudgetGuard? _guard;
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null, bool acceptLineTasks = true)
{
_trust = trust ?? TrustAnchor.None;
_acceptLineTasks = acceptLineTasks;
_guardFactory = guardFactory;
_streams = streams;
_env = env;
@@ -46,43 +48,43 @@ public sealed class TaskManager
public BudgetGuard Guard => _guard ??= _guardFactory();
/// <summary>某个 Probe 的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个 Probe。</summary>
public Action<ProbeSession, LineTaskSet>? OnLineTasksChanged { get; set; }
/// <summary>某个拨号方的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个拨号方。</summary>
public Action<DialerSession, LineTaskSet>? OnLineTasksChanged { get; set; }
/// <summary>允许连接的 Probe 变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
/// <summary>允许连接的拨号方变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
public Action<IReadOnlySet<string>>? OnPeersChanged { get; set; }
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
/// <summary>点对点握手时查找拨号方的公钥(SPKI)。</summary>
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
/// <summary>名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。</summary>
/// <summary>名册里全部拨号方的公钥:敲门关卡用它们派生配对密钥。</summary>
public IReadOnlyList<byte[]> PeerKeys() => [.. _peers.Values];
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(ProbeSession probe)
/// <summary>一个新的拨号方会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(DialerSession dialer)
{
lock (_gate)
{
var entries = new Dictionary<string, LineEntry>();
foreach ((string id, (TaskSpec task, TaskDecision decision)) in _lineSpecs)
{
LineEntry entry = BuildLineEntry(task, decision, probe);
LineEntry entry = BuildLineEntry(task, decision, dialer);
Start(entry);
entries[id] = entry;
}
_sessionLines[probe] = entries;
_sessionLines[dialer] = entries;
return SetFor(entries);
}
}
/// <summary>Probe 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
public async Task CloseSessionAsync(ProbeSession probe)
/// <summary>拨号方 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
public async Task CloseSessionAsync(DialerSession dialer)
{
List<LineEntry> entries;
lock (_gate)
{
if (!_sessionLines.Remove(probe, out Dictionary<string, LineEntry>? lines))
if (!_sessionLines.Remove(dialer, out Dictionary<string, LineEntry>? lines))
{
return;
}
@@ -108,11 +110,11 @@ public sealed class TaskManager
{
try
{
peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey);
peers[peer.NodeId.Value] = Convert.FromBase64String(peer.PublicKey);
}
catch (FormatException)
{
result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.InvalidParams });
result.Rejected.Add(new RejectedItem { Id = peer.NodeId.Value, Reason = RejectReasons.InvalidParams });
}
}
@@ -131,7 +133,7 @@ public sealed class TaskManager
continue;
}
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : s_lineKinds.Contains(task.Kind);
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : _acceptLineTasks && s_lineKinds.Contains(task.Kind);
TaskDecision decision = shapeOk ? guard.Check(task, ref usedMbps, ref usedPps) : TaskDecision.Reject(RejectReasons.Unsupported);
if (!decision.Accepted)
{
@@ -153,18 +155,18 @@ public sealed class TaskManager
// 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断
var retired = new List<LineEntry>();
var changes = new List<(ProbeSession Probe, LineTaskSet Set)>();
var changes = new List<(DialerSession Dialer, LineTaskSet Set)>();
lock (_gate)
{
_lineSpecs = line;
_version = version;
foreach ((ProbeSession probe, Dictionary<string, LineEntry> current) in _sessionLines)
foreach ((DialerSession dialer, Dictionary<string, LineEntry> current) in _sessionLines)
{
var next = new Dictionary<string, LineEntry>();
bool lineTasksChanged = current.Count != line.Count;
foreach ((string id, (TaskSpec task, TaskDecision decision)) in line)
{
LineEntry candidate = BuildLineEntry(task, decision, probe);
LineEntry candidate = BuildLineEntry(task, decision, dialer);
if (current.TryGetValue(id, out LineEntry? existing) && existing.Signature == candidate.Signature)
{
next[id] = existing;
@@ -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<string>(peers.Keys, StringComparer.Ordinal));
foreach ((ProbeSession probe, LineTaskSet next) in changes)
foreach ((DialerSession dialer, LineTaskSet next) in changes)
{
OnLineTasksChanged?.Invoke(probe, next);
OnLineTasksChanged?.Invoke(dialer, next);
}
return result;
}
/// <summary>停止时释放所有 Probe 会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
/// <summary>停止时释放所有拨号方会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
public async Task StopAsync()
{
_targets.Reconcile([]);
@@ -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));
}
/// <summary>在数据面登记这个流并启动它的监听方积木。</summary>
@@ -8,9 +8,9 @@ namespace MoShiro.Agent;
/// <summary>
/// 按需测试的编排(<c>moshiro.test.run</c>):Agent 是时间的主人。每个阶段:
/// 登记数据面的流 → <c>moshiro.test.phase</c>(Probe 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待 Probe 发包)
/// → <c>moshiro.test.phase_end</c>(Probe 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
/// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;Probe 掉线或被取消时测试中止,已经上传的阶段数据保留。
/// 登记数据面的流 → <c>moshiro.test.phase</c>(拨号方 准备好自己一侧的角色)→ 运行(单向数据流由 Agent 发包,回显类等待拨号方发包)
/// → <c>moshiro.test.phase_end</c>(拨号方 收尾,回复它交出的列块个数)→ 等所有列块合并上传 → <c>phase_done</c>。
/// 同一时间只能运行 <c>max_concurrent_tests</c> 个测试;拨号方 掉线或被取消时测试中止,已经上传的阶段数据保留。
/// </summary>
public sealed class TestRunner
{
@@ -51,9 +51,9 @@ public sealed class TestRunner
return Result<TestRunResult>.Fail(MoShiroErrors.ValidationFailed, "ip_version must be ipv4 or ipv6");
}
if (_sessions.Get(run.ProbeId.Value, run.IpVersion) is not { } probe)
if (_sessions.Get(run.DialerId.Value, run.IpVersion) is not { } dialer)
{
return Result<TestRunResult>.Fail(MoShiroErrors.ProbeOffline, $"probe {run.ProbeId.Value} is not connected to this agent");
return Result<TestRunResult>.Fail(MoShiroErrors.DialerOffline, $"dialer {run.DialerId.Value} is not connected to this agent");
}
if (run.Phases.Count == 0)
@@ -85,7 +85,7 @@ public sealed class TestRunner
resolved.Add((phase, _tasks.Guard.CheckPhase(phase.Kind, phase.Params)));
}
var running = new RunningTest(probe);
var running = new RunningTest(dialer);
if (_running.Count >= _maxConcurrent || !_running.TryAdd(run.TestId.Value, running))
{
running.Dispose();
@@ -126,12 +126,12 @@ public sealed class TestRunner
return false;
}
/// <summary>某个 Probe 掉线:它上面的测试中止,状态是 failed / probe_offline;别的 Probe 上的测试不受影响。</summary>
public void ProbeLost(ProbeSession probe)
/// <summary>某个拨号方掉线:它上面的测试中止,状态是 failed / dialer_offline;别的拨号方上的测试不受影响。</summary>
public void DialerLost(DialerSession dialer)
{
foreach (RunningTest running in _running.Values)
{
if (ReferenceEquals(running.Probe, probe))
if (ReferenceEquals(running.Dialer, dialer))
{
running.Lost = true;
running.Cancel.Cancel();
@@ -147,26 +147,26 @@ public sealed class TestRunner
int? lastPhase = null;
try
{
ProbeSession probe = running.Probe;
DialerSession dialer = running.Dialer;
for (int index = 0; index < phases.Count; index++)
{
lastPhase = index;
await _uplink.SendTestStatusAsync(test, TestStatuses.Running, index);
await RunPhaseAsync(probe, test, index, phases[index].Phase, phases[index].Decision, ct);
await RunPhaseAsync(dialer, test, index, phases[index].Phase, phases[index].Decision, ct);
await _uplink.SendTestStatusAsync(test, TestStatuses.PhaseDone, index);
}
}
catch (OperationCanceledException)
{
finalStatus = running.Lost ? TestStatuses.Failed : TestStatuses.Aborted;
errorCode = running.Lost ? MoShiroErrors.ProbeOffline.Value : null;
await TryAbortProbeAsync(running.Probe, test);
errorCode = running.Lost ? MoShiroErrors.DialerOffline.Value : null;
await TryAbortDialerAsync(running.Dialer, test);
}
catch (TestFailure failure)
{
finalStatus = TestStatuses.Failed;
errorCode = failure.Code;
await TryAbortProbeAsync(running.Probe, test);
await TryAbortDialerAsync(running.Dialer, test);
}
finally
{
@@ -178,17 +178,17 @@ public sealed class TestRunner
await _uplink.SendTestStatusAsync(test, finalStatus, lastPhase, errorCode);
}
private async Task RunPhaseAsync(ProbeSession probe, TestId test, int index, TestPhase phase, TaskDecision decision, CancellationToken ct)
private async Task RunPhaseAsync(DialerSession dialer, TestId test, int index, TestPhase phase, TaskDecision decision, CancellationToken ct)
{
ParamValues values = decision.Resolved!;
var stream = new StreamInfo(_streams.Allocate(), phase.Kind, values) { TestId = test, Phase = index, Owner = probe };
var stream = new StreamInfo(_streams.Allocate(), phase.Kind, values) { TestId = test, Phase = index, Owner = dialer };
int durationSeconds = phase.DurationSeconds > 0 ? phase.DurationSeconds : (int)(values.Values.ContainsKey("duration_s") ? values.GetInt("duration_s") : 10);
var blockSpec = new BlockSpec(decision.Kind!, values, stream.Id, stream.Port, new FragmentScope(null, test, index));
stream.Running = RunningBlock.Start(BlockCatalog.Find(phase.Kind)!.Listener!(_env.WithSession(probe), blockSpec), blockSpec.Signature);
stream.Running = RunningBlock.Start(BlockCatalog.Find(phase.Kind)!.Listener!(_env.WithSession(dialer), blockSpec), blockSpec.Signature);
_streams.Add(stream);
try
{
RpcSession<PeerContext> rpc = probe.Rpc ?? throw new TestFailure(MoShiroErrors.ProbeOffline.Value);
RpcSession<PeerContext> rpc = dialer.Rpc ?? throw new TestFailure(MoShiroErrors.DialerOffline.Value);
var start = new TestPhaseStartParams
{
TestId = test,
@@ -214,7 +214,7 @@ public sealed class TestRunner
}
catch (BlockFaultException)
{
throw new TestFailure(MoShiroErrors.ProbeOffline.Value);
throw new TestFailure(MoShiroErrors.DialerOffline.Value);
}
}
else
@@ -230,7 +230,7 @@ public sealed class TestRunner
throw new TestFailure(endError.Code.Value);
}
// 列块是经 Probe 的发件箱至少一次送达的:全部合并上传之后才进入下一阶段(超时就带着已有的数据继续)
// 列块是经拨号方的发件箱至少一次送达的:全部合并上传之后才进入下一阶段(超时就带着已有的数据继续)
DateTimeOffset deadline = _time.GetUtcNow() + s_fragmentWait;
while (stream.FragmentsDone < finished.Fragments && _time.GetUtcNow() < deadline)
{
@@ -246,13 +246,13 @@ public sealed class TestRunner
{
_streams.Remove(stream.Id);
await stream.Running.StopAsync();
probe.ForgetStream(stream.Id);
dialer.ForgetStream(stream.Id);
}
}
private async Task TryAbortProbeAsync(ProbeSession probe, TestId test)
private async Task TryAbortDialerAsync(DialerSession dialer, TestId test)
{
if (probe.Rpc is not { } rpc)
if (dialer.Rpc is not { } rpc)
{
return;
}
@@ -267,15 +267,15 @@ public sealed class TestRunner
}
}
/// <summary>一个正在运行的测试:它在哪个 Probe 上、怎样取消、是不是因为 Probe 掉线而中止。</summary>
/// <summary>一个正在运行的测试:它在哪个拨号方上、怎样取消、是不是因为拨号方掉线而中止。</summary>
private sealed class RunningTest : IDisposable
{
public RunningTest(ProbeSession probe)
public RunningTest(DialerSession dialer)
{
Probe = probe;
Dialer = dialer;
}
public ProbeSession Probe { get; }
public DialerSession Dialer { get; }
public CancellationTokenSource Cancel { get; } = new();
@@ -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();
@@ -2,28 +2,28 @@ using Fleet.Core;
using Fleet.Hosting;
using MoShiro.Protocol;
namespace MoShiro.Probe.Tests;
namespace MoShiro.Agent.Tests;
public sealed class ProbeTests
public sealed class DialDirectoryTests
{
[Fact]
public void AgentDirectory_DualStack_PersistsBothPaths_AndRemovingIpv6KeepsIpv4()
public void DialDirectory_DualStack_PersistsBothPaths_AndRemovingIpv6KeepsIpv4()
{
string directory = NewDirectory();
try
{
var agents = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
ProbeAgentList list = List(1, "agt_dual");
list.Agents[0].Ipv6Host = "2001:db8::1";
var agents = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
DialTargets list = List(1, "agt_dual");
list.Targets[0].Ipv6Host = "2001:db8::1";
agents.Apply(list);
Assert.Equal(new[] { "ipv4", "ipv6" }, agents.Agents.Select(a => a.IpVersion));
var reloaded = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Equal(2, reloaded.Agents.Count);
Assert.Equal("2001:db8::1", reloaded.Agents[1].Host);
Assert.Equal(new[] { "ipv4", "ipv6" }, agents.Targets.Select(a => a.IpVersion));
var reloaded = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Equal(2, reloaded.Targets.Count);
Assert.Equal("2001:db8::1", reloaded.Targets[1].Host);
list.Version = 2;
list.Agents[0].Ipv6Host = null;
list.Targets[0].Ipv6Host = null;
reloaded.Apply(list);
Assert.Equal("ipv4", Assert.Single(reloaded.Agents).IpVersion);
Assert.Equal("ipv4", Assert.Single(reloaded.Targets).IpVersion);
}
finally
{
@@ -38,43 +38,43 @@ public sealed class ProbeTests
Assert.False(clock.HasEstimate);
// Offset 1000 with a 50 ms round trip, offset 5000 with a 2 ms round trip (symmetry assumption is far better here)
clock.Add(sendUs: 0, agentUs: 25_000 + 1000, receiveUs: 50_000);
clock.Add(sendUs: 100_000, agentUs: 101_000 + 5000, receiveUs: 102_000);
clock.Add(sendUs: 0, listenerUs: 25_000 + 1000, receiveUs: 50_000);
clock.Add(sendUs: 100_000, listenerUs: 101_000 + 5000, receiveUs: 102_000);
Assert.True(clock.HasEstimate);
Assert.Equal(5000, clock.OffsetUs);
// Slow samples do not displace the good one
clock.Add(sendUs: 200_000, agentUs: 300_000, receiveUs: 400_000);
clock.Add(sendUs: 200_000, listenerUs: 300_000, receiveUs: 400_000);
Assert.Equal(5000, clock.OffsetUs);
}
private static string NewDirectory()
{
string path = Path.Combine(Path.GetTempPath(), "probe-tests-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(Path.GetTempPath(), "dial-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static ProbeAgentList List(long version, params string[] ids) => new()
private static DialTargets List(long version, params string[] ids) => new()
{
Version = version,
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, PublicKey = "c3BraQ==" })],
Targets = [.. ids.Select(id => new DialTarget { NodeId = new NodeId(id), Host = id + ".example", TcpPort = 7443, PublicKey = "c3BraQ==" })],
};
[Fact]
public void AgentDirectory_StartsEmpty_IgnoresStaleLists_PersistsChanges_AndReloadsThem()
public void DialDirectory_StartsEmpty_IgnoresStaleLists_PersistsChanges_AndReloadsThem()
{
string directory = NewDirectory();
try
{
var directoryA = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
var directoryA = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
int changes = 0;
directoryA.Changed += () => changes++;
Assert.Empty(directoryA.Agents);
Assert.Empty(directoryA.Targets);
directoryA.Apply(List(5, "agt_1", "agt_2"));
Assert.Equal(1, changes);
Assert.Equal(["agt_1", "agt_2"], directoryA.Agents.Select(a => a.AgentId));
Assert.Equal(["agt_1", "agt_2"], directoryA.Targets.Select(a => a.NodeId));
directoryA.Apply(List(4, "agt_old")); // out-of-order older push
directoryA.Apply(List(5, "agt_1", "agt_2")); // same version and content
@@ -82,8 +82,8 @@ public sealed class ProbeTests
Assert.Equal(5, directoryA.Version);
// The next start uses the saved list while the panel is not reachable yet
var directoryB = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Equal(["agt_1", "agt_2"], directoryB.Agents.Select(a => a.AgentId));
var directoryB = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Equal(["agt_1", "agt_2"], directoryB.Targets.Select(a => a.NodeId));
Assert.Equal(5, directoryB.Version);
}
finally
@@ -93,14 +93,14 @@ public sealed class ProbeTests
}
[Fact]
public void AgentDirectory_StartsEmpty_WhenTheSavedFileIsCorrupt()
public void DialDirectory_StartsEmpty_WhenTheSavedFileIsCorrupt()
{
string directory = NewDirectory();
try
{
new ConfigStore(directory).Write(AgentDirectory.FileName, "{ not json"u8.ToArray());
var loaded = new AgentDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Empty(loaded.Agents);
new ConfigStore(directory).Write(DialDirectory.FileName, "{ not json"u8.ToArray());
var loaded = new DialDirectory(new ConfigStore(directory), directory, NullNodeLog.Instance);
Assert.Empty(loaded.Targets);
}
finally
{
@@ -0,0 +1,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<Exception, string> Faults => new()
{
{ new OperationCanceledException(), "timeout" },
{ new SocketException((int)SocketError.ConnectionRefused), "refused" },
{ new SocketException((int)SocketError.HostNotFound), "dns" },
{ new IOException("reset", new SocketException((int)SocketError.ConnectionReset)), "reset" },
{ new AuthenticationException("bad certificate"), "tls" },
{ new InvalidOperationException("peer authentication failed: unauthorized"), "auth" },
{ new ControlSilentException("no frames"), "silent" },
{ new IOException("closed"), "closed" },
};
[Theory]
[MemberData(nameof(Faults))]
public void LinkFaults_ClassifyTheFailureAndKeepThePhase(Exception exception, string kind)
{
LinkFault fault = LinkFaults.From(exception, "tls");
Assert.Equal(kind, fault.Kind);
Assert.Equal("tls", fault.Phase);
}
[Fact]
public void ControlStatus_LogsEveryTransition_AndReportsTheFaultWhileInBackoff()
{
var log = new CaptureLog();
var status = new LinkStatus("agt_1", "ipv4", TimeProvider.System, log);
status.SetControl(ControlState.Dialing);
status.SetControl(ControlState.Authenticating);
status.SetControl(ControlState.Syncing);
status.SetControl(ControlState.Up);
status.SetControl(ControlState.Backoff, new LinkFault { Phase = "control_session", Kind = "silent" }, attempt: 1, retryIn: TimeSpan.FromSeconds(1));
Assert.Equal(["authenticating", "syncing", "up", "backoff"], log.Items.Select(x => (string)x["to"]!));
LinkHealth health = status.Snapshot(new Outbox(1 << 10), 0, null);
Assert.Equal(ControlState.Backoff, health.Control.State);
Assert.Equal("silent", health.Control.LastError?.Kind);
Assert.InRange(health.Control.RetryInMs ?? -1, 1, 1000);
status.SetControl(ControlState.Up);
Assert.Null(status.Snapshot(new Outbox(1 << 10), 0, null).Control.LastError);
}
[Fact]
public void QuicStatus_LogsOnlyTheEdgesOfAnOutage_NotEveryRedial()
{
var log = new CaptureLog();
var status = new LinkStatus("agt_1", "ipv4", TimeProvider.System, log);
var fault = new LinkFault { Phase = "quic_handshake", Kind = "timeout" };
status.SetQuic(QuicState.Connecting);
status.SetQuic(QuicState.Ready);
status.SetQuic(QuicState.Retrying, fault, 1, TimeSpan.FromSeconds(1));
for (int attempt = 1; attempt <= 5; attempt++)
{
status.SetQuic(QuicState.Connecting, attempt: attempt);
status.SetQuic(QuicState.Retrying, fault, attempt + 1, TimeSpan.FromSeconds(5));
}
status.SetQuic(QuicState.Connecting, attempt: 6);
status.SetQuic(QuicState.Ready);
Assert.Equal(["ready", "retrying", "ready"], log.Items.Select(x => (string)x["to"]!));
Assert.NotNull(log.Items[^1]["down_ms"]);
Assert.True(status.Ready is false);
status.SetControl(ControlState.Up);
Assert.True(status.Ready);
}
private sealed class CaptureLog : INodeLog
{
private readonly ConcurrentQueue<Dictionary<string, object?>> _items = new();
public IReadOnlyList<Dictionary<string, object?>> Items => [.. _items];
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
if (message == "link state")
{
_items.Enqueue(fields.ToDictionary(x => x.Key, x => x.Value));
}
}
}
}
@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\src\MoShiro.Probe\MoShiro.Probe.csproj" />
<ProjectReference Include="..\..\src\MoShiro.Agent\MoShiro.Agent.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
@@ -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
{
@@ -0,0 +1,58 @@
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Fleet.Core;
using Fleet.Link;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Agent.Tests;
public sealed class TaskLinkTests
{
[Fact]
public async Task QuicConnectionLoss_IsRedialedFromANewPort_WhileTheControlLayerStaysUp()
{
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256);
using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddYears(100));
var session = new DataSession(1, new byte[32]);
var clock = new MonoClock(TimeProvider.System);
await using var server = new QuicDatagramTransport(new SingleSession(session), null, clock, new DataPlaneCounters(), NullNodeLog.Instance,
IPAddress.Loopback, [0], certificate);
server.Bind();
await using var client = new QuicDatagramTransport(session, clock, new DataPlaneCounters(), NullNodeLog.Instance,
new IPEndPoint(IPAddress.Loopback, server.BoundPorts[0]), key.ExportSubjectPublicKeyInfo());
var status = new LinkStatus("agt_1", "ipv4", TimeProvider.System, NullNodeLog.Instance);
status.SetControl(ControlState.Up);
using var link = new TaskLink(client, status, TimeProvider.System);
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(30));
Task run = link.RunAsync(stop.Token);
await WaitUntilAsync(() => status.Quic == QuicState.Ready, stop.Token);
int firstPort = client.BoundPorts[0];
await client.CloseClientAsync();
bool controlStayedUp = true;
await WaitUntilAsync(() =>
{
controlStayedUp &= status.Control == ControlState.Up;
return status.Quic == QuicState.Ready && client.BoundPorts[0] != firstPort;
}, stop.Token);
Assert.True(controlStayedUp);
LinkHealth health = status.Snapshot(new Outbox(1 << 10), 0, null);
Assert.Equal(2, health.Task.Quic.HandshakeSuccesses);
Assert.Equal(ControlState.Up, health.Control.State);
await stop.CancelAsync();
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
private static async Task WaitUntilAsync(Func<bool> condition, CancellationToken ct)
{
while (!condition())
{
await Task.Delay(20, ct);
}
}
}
+2 -2
View File
@@ -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:路由、二进制通道、发件箱
+1 -1
View File
@@ -9,7 +9,7 @@ public enum NodeLogLevel
Error,
}
/// <summary>节点日志:字段名统一为 logfmt(<c>ts level msg agent probe session task result err</c>)。逐包事件不记录,只累加计数。</summary>
/// <summary>节点日志:字段名统一为 logfmt(<c>ts level msg agent dialer session task result err</c>)。逐包事件不记录,只累加计数。</summary>
public interface INodeLog
{
void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields);
+1 -1
View File
@@ -287,7 +287,7 @@ public static class NodeConfig
/// <summary>
/// 常驻业务进程里唯一允许写文件的地方,而且只能写配置/身份目录(原子替换,类 Unix 上权限 0600)。
/// 例如 Probe 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。
/// 例如 拨号方 把最新的 Agent 列表写回 bootstrap。测量数据本来就不落盘。
/// </summary>
public sealed class ConfigStore
{
@@ -3,7 +3,7 @@ using Fleet.Core;
namespace Fleet.Hosting;
/// <summary>
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
/// 节点自己要求有属主(passkey):拨号方和监听方共用这一份,启动时检查。
/// <para>
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
+12 -6
View File
@@ -22,7 +22,7 @@ public sealed class PeerListenerOptions
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
public required Func<string, byte[]?> LookupPeerKey { get; init; }
/// <summary>预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
/// <summary>预握手敲门:每个连接先过这一关(名册里的 拨号方 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
public required KnockGate Knock { get; init; }
public int MaxFrameBytes { get; init; } = 8 << 20;
@@ -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
}
}
/// <summary>发起点对点会话(Probe 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
/// <summary>发起点对点会话(拨号方 一侧):按 SPKI 指纹固定对端证书,用身份私钥签名,派生同样的数据面凭据。</summary>
/// <summary>点对点会话的套接字:开启 TCP keepalive(空闲 30 秒后每 10 秒探测,连续 6 次无响应才断开,约 90 秒),主机真的没了的时候连接不会一直挂着;
/// 但线路短暂中断(几十秒的黑洞)不能把会话杀掉——线路中断本来就是要测的东西,会话得扛过去,恢复之后接着测。</summary>
internal static class PeerSockets
@@ -249,7 +249,7 @@ internal static class PeerSockets
}
}
/// <summary>点对点会话的时间常数,Probe 和 Agent 两侧共用。</summary>
/// <summary>点对点会话的时间常数,拨号方和监听方两侧共用。</summary>
public static class PeerTimings
{
/// <summary>建立会话(连接、敲门、TLS、挑战应答)的总时限。</summary>
@@ -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<string>? 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();
+1 -1
View File
@@ -112,7 +112,7 @@ public sealed class WsTransport : IRpcTransport
}
/// <summary>
/// TLS 帧传输(Probe → Agent):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
/// TLS 帧传输(拨号方 → 监听方):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
/// 比 WebSocket 轻得多,Agent 不需要内置 HTTP 服务端。
/// </summary>
public sealed class TlsFrameTransport : IRpcTransport
+2 -2
View File
@@ -18,7 +18,7 @@ public static class FleetMethods
/// <summary>
/// 框架自己的方法声明,两个平面各一份。认证和握手阶段由连接代码直接处理的(<c>Inline</c>)不经过路由器。
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 Agent 发给 Probe。
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 监听方发给拨号方。
/// </summary>
public static class FleetMethodTable
{
@@ -91,7 +91,7 @@ public sealed class HelloParams
[JsonPropertyName("now_utc_ms")]
public long NowUtcMs { get; set; }
/// <summary>产品自定义的元数据(例如 MoShiro 的 tls_spki、probe_ports、local_limits 摘要)。</summary>
/// <summary>产品自定义的元数据(例如 MoShiro 的 tls_spki、listen 信息、local_limits 摘要)。</summary>
[JsonPropertyName("meta")]
public JsonElement? Meta { get; set; }
}
+1 -1
View File
@@ -1,6 +1,6 @@
namespace Fleet.Rpc;
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方(Probe)和监听方(Agent)。</summary>
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方和监听方。</summary>
public enum RpcSide
{
Server,
+1 -1
View File
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
namespace Fleet.Rpc;
/// <summary>
/// 节点间点对点会话的握手(Probe → Agent,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥,
/// 节点间点对点会话的握手(拨号方 → 监听方,TLS 帧)。除了签名认证,双方各生成一个临时 ECDH 密钥,
/// 经过 HKDF 派生出数据面使用的 <c>session_id</c> 与 <c>session_key</c>(所以不需要单独的“下发密钥”方法)。
/// </summary>
public static class PeerMethods
+1 -1
View File
@@ -11,7 +11,7 @@ public readonly record struct RpcViolation(string Method, string Reason);
public readonly record struct RpcDispatch(RpcMessage? Response, RpcViolation? Violation);
/// <summary>
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、Probe 和 Agent 的入站分发都经过这里。
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、拨号方和监听方 的入站分发都经过这里。
/// <typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。路由器知道自己在连接的哪一侧(<see cref="Side"/>)和声明表,
/// 注册处理函数时核对方向、类型和角色,分发时拒绝没有声明或不该由对方发起的方法,发送时核对自己有没有资格发起。
/// </summary>
+1 -1
View File
@@ -17,7 +17,7 @@ public readonly record struct RpcFrame(FrameKind Kind, ReadOnlyMemory<byte> Data
/// <summary>
/// 一条双向的、保序的消息通道:文本帧承载 JSON-RPC,二进制帧承载发件箱。
/// 节点到服务端用 WebSocket,Probe 到 Agent 用 TLS 帧;会话引擎对两者一视同仁。
/// 节点到服务端用 WebSocket,拨号方到监听方 用 TLS 帧;会话引擎对两者一视同仁。
/// </summary>
public interface IRpcTransport : IAsyncDisposable
{
@@ -5,7 +5,7 @@ public sealed class FleetServerOptions
/// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary>
public string NodeIdPrefix { get; set; } = "node";
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>probe</c> → <c>prb</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>edge</c> → <c>edg</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
public Dictionary<string, string> NodeIdPrefixes { get; } = new(StringComparer.Ordinal);
public int MaxFrameBytes { get; set; } = 16 << 20;
@@ -69,7 +69,7 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
/// <summary>处理一条 WebSocket 连接,直到连接结束。</summary>
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);
}
@@ -29,10 +29,10 @@ public sealed class FleetServerBuilder
internal Func<NodeRecord, string?>? RoleOf => _roleOf;
/// <summary>
/// 方法声明表(框架的加产品的,见 <see cref="MethodTable.Combine"/>)和节点的角色解析(例如按节点 ID 前缀区分 Agent 和 Probe)。必须设置:
/// 方法声明表(框架的加产品的,见 <see cref="MethodTable.Combine"/>)和可选的节点角色解析(声明表里限定了角色的方法才需要)。方法表必须设置:
/// 路由器据此核对每个处理函数的方向、类型和角色,并拒绝没有声明或不该由节点发起的方法。
/// </summary>
public FleetServerBuilder WithMethods(MethodTable methods, Func<NodeRecord, string?> roleOf)
public FleetServerBuilder WithMethods(MethodTable methods, Func<NodeRecord, string?>? roleOf = null)
{
_methods = methods;
_roleOf = roleOf;
@@ -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;
}
@@ -11,7 +11,7 @@ namespace Fleet.Server;
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
/// </summary>
internal sealed partial class NodeConnection : IDisposable
internal sealed partial class NodeConnection : IAsyncDisposable
{
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
@@ -31,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();
}
}
}
+14 -14
View File
@@ -15,14 +15,14 @@ public sealed class KnockTests
[Fact]
public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair()
{
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity dialer = NodeIdentity.Generate();
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity other = NodeIdentity.Generate();
byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki);
byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki);
Assert.Equal(fromProbe, fromAgent);
Assert.Equal(32, fromProbe.Length);
Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki));
byte[] fromDialer = Knock.DeriveKey(dialer, agent.PublicKeySpki);
byte[] fromAgent = Knock.DeriveKey(agent, dialer.PublicKeySpki);
Assert.Equal(fromDialer, fromAgent);
Assert.Equal(32, fromDialer.Length);
Assert.NotEqual(fromDialer, Knock.DeriveKey(other, agent.PublicKeySpki));
}
[Fact]
@@ -61,36 +61,36 @@ public sealed class KnockTests
public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity dialer = NodeIdentity.Generate();
using NodeIdentity stranger = NodeIdentity.Generate();
var roster = new List<byte[]> { probe.PublicKeySpki };
var roster = new List<byte[]> { dialer.PublicKeySpki };
var gate = new KnockGate(agent, () => roster, new Clock());
long now = 1_700_000_000_000;
byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now);
byte[] packet = Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now);
Assert.True(gate.Accept(packet, now));
Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
// 名册变了:新成员马上能进,被移除的马上进不来
using NodeIdentity second = NodeIdentity.Generate();
roster.Add(second.PublicKeySpki);
Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now));
roster.RemoveAt(0);
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now));
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now), now));
}
[Fact]
public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity dialer = NodeIdentity.Generate();
var clock = new Clock();
var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3);
var gate = new KnockGate(agent, () => [dialer.PublicKeySpki], clock, attemptsPerPeriod: 3);
IPAddress noisy = IPAddress.Parse("203.0.113.9");
IPAddress quiet = IPAddress.Parse("203.0.113.10");
byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki);
byte[] key = Knock.DeriveKey(dialer, agent.PublicKeySpki);
for (int i = 0; i < 3; i++)
{
@@ -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)
+33 -33
View File
@@ -328,10 +328,10 @@ public sealed class LinkTests
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
/// <summary>Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。</summary>
/// <summary>Agent 的 TLS 证书公钥:拨号方 用它固定证书、派生敲门密钥。</summary>
public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo();
/// <summary>名册:能敲开门的 Probe 公钥。</summary>
/// <summary>名册:能敲开门的 拨号方 公钥。</summary>
public List<byte[]> KnockPeers { get; } = [];
public async ValueTask DisposeAsync()
@@ -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<PeerSessionInfo>();
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null, (info, transport, ct) =>
await using var agent = new PeerFixture(id => id == "dlr_1" ? dialer.PublicKeySpki : null, (info, transport, ct) =>
{
seen.TrySetResult(info);
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
});
agent.KnockPeers.Add(probe.PublicKeySpki);
agent.KnockPeers.Add(dialer.PublicKeySpki);
await agent.Listener.Started.Task;
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<InvalidOperationException>(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<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "dlr_1", null, 1 << 20, TimeProvider.System, default));
Assert.Equal(unknown.Message, forged.Message);
Assert.Contains("fleet_unauthorized", forged.Message);
// 正主仍然可以连接
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
[Fact]
public async Task PeerHandshake_PinsTheAgentCertificate_ByItsSpkiFingerprint()
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
using NodeIdentity dialer = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
agent.KnockPeers.Add(dialer.PublicKeySpki);
await agent.Listener.Started.Task;
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到)
using NodeIdentity another = NodeIdentity.Generate();
await Assert.ThrowsAnyAsync<Exception>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default));
}
[Fact]
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
{
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity dialer = NodeIdentity.Generate();
MethodTable table = TestTables.Of(TestTables.Req("agent.echo"));
var router = new RpcRouter<string>(RpcSide.Listener, table).On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
await using var agent = new PeerFixture(
_ => probe.PublicKeySpki,
_ => dialer.PublicKeySpki,
async (info, transport, ct) =>
{
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
await session.RunAsync(ct);
});
agent.KnockPeers.Add(probe.PublicKeySpki);
agent.KnockPeers.Add(dialer.PublicKeySpki);
await agent.Listener.Started.Task;
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System);
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "dialer", TimeProvider.System);
Task run = client.RunAsync(default);
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(42, result.Value.Value);
@@ -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();
}
@@ -34,10 +34,10 @@ public sealed class MethodDirectionTests
// t.ping 声明成请求:不能按通知注册
Assert.Throws<InvalidOperationException>(() => server.OnNotify("t.ping", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
// 节点这一侧:t.note 只给 agent 角色,probe 注册不了;t.push 是它该处理的
var probe = new RpcRouter<string>(RpcSide.Node, s_table, "probe");
probe.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "probe").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
// 节点这一侧:t.note 只给 agent 角色,dialer 注册不了;t.push 是它该处理的
var dialer = new RpcRouter<string>(RpcSide.Node, s_table, "dialer");
dialer.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "dialer").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
server.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
}
@@ -99,7 +99,7 @@ public sealed class MethodDirectionTests
{
RpcRouter<string> server = Server().OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask);
Assert.Null((await server.DispatchCheckedAsync("agent", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync("probe", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync("dialer", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync(null!, Notification("t.note"), default)).Violation);
}
@@ -115,7 +115,7 @@ public sealed class MethodDirectionTests
// 对端的角色也要允许
var node = new RpcRouter<string>(RpcSide.Node, s_table, "agent");
node.CheckSend("t.note", MethodKind.Notification, "agent");
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "probe"));
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "dialer"));
}
[Fact]
@@ -126,7 +126,7 @@ public sealed class MethodDirectionTests
// 服务端要处理 t.ping 和 t.note;t.inline 在握手阶段直接处理,不算缺
Assert.Equal(["t.note", "t.ping"], s_table.MissingHandlers(RpcSide.Server, null, []));
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "probe", []));
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "dialer", []));
Assert.Equal(["t.note"], s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping"]));
Assert.Empty(s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping", "t.note"]));
// 同名方法在对等面的接收方是拨号方?不:t.ping 在对等面由监听方发起,拨号方处理
@@ -4,7 +4,7 @@ using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
/// <summary>节点自己要求有属主(passkey):拨号方和监听方共用这一份,没有属主就不运行。</summary>
public sealed class OwnerGuardTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
+1 -1
View File
@@ -2,7 +2,7 @@ using System.Text.Json.Nodes;
using Fleet.Core;
using Fleet.Enrollment.Client;
// fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/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();
+204 -543
View File
File diff suppressed because it is too large. Load diff
-21
View File
@@ -1,21 +0,0 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<OptimizationPreference>Size</OptimizationPreference>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<StackTraceSupport>false</StackTraceSupport>
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../BannedSymbols.Node.txt" />
</ItemGroup>
</Project>
-67
View File
@@ -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:<version>`,容器默认读取 `/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++ 工具链;`<RID>` 应与构建环境和目标平台匹配:
```sh
dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
```
也可在仓库根目录运行 `bash scripts/publish.sh probe <RID>`,再使用 `scripts/package.sh probe <RID>` 生成部署包。支持平台和安装流程见 [`../../docs/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:<version>`,自动选择 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。首次导入和自动更新见部署文档。
@@ -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;
/// <summary>所有连接共用的东西。</summary>
public sealed record ConnectionEnv(NodeIdentity Identity, string ProbeId, ProbeLimits Limits, AgentDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
/// <summary>
/// 与一个 Agent 的点对点会话:认证握手 → 同步时钟 → 按 Agent 下发的 <c>line_task_set</c> 运行角色 →
/// 每 10 秒把已完成的行切成列块放进发件箱 → 响应按需测试的阶段。会话断开时所有角色停止(凭据属于会话),按退避重连。
/// 会话内的所有状态(套接字、发件箱、角色)随会话一起创建和释放。
/// </summary>
public sealed class AgentConnection : IDisposable
{
private static readonly TimeSpan s_flushInterval = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
/// <summary>Agent 的任何帧(时钟回复、任务集)都算存活;比时钟同步间隔长得多,容得下差线路上的黑洞,但不会让一个 Agent 已经丢掉的会话永远挂着。</summary>
private static readonly TimeSpan s_idleLimit = TimeSpan.FromSeconds(120);
/// <summary>一直在发测量报文、却这么久没有收到 Agent 的任何一个:数据面已经不通,重建会话。要比一次线路中断长(中断恢复后接着测),又不能拖到几个小时。</summary>
private static readonly TimeSpan s_dataIdleLimit = TimeSpan.FromSeconds(90);
private static readonly RpcRouter<ConnectionSession> s_router = BuildRouter();
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
public static RpcRouter<ConnectionSession> 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<double> _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;
/// <summary>管理端手动配对时,跳过当前退避等待,马上再拨一次。</summary>
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<string> 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<int> 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<ConnectionSession>(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<Task> work)
{
if (!await Bounded.WaitAsync(work(), Bounded.Teardown, _env.Time))
{
_attemptLog.Warn("agent session teardown step timed out, moving on", ("phase", _phase), ("step", step), ("timeout_ms", Bounded.Teardown.TotalMilliseconds));
}
}
private static async Task Quiet(Task task)
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException or TimeoutException)
{
// 会话结束时的正常噪声
}
}
private static async Task PumpAsync(RpcSession<ConnectionSession> rpc, Outbox outbox, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[] frame = await outbox.TakeNextAsync(2 << 20, ct);
await rpc.SendBinaryAsync(frame, ct);
}
}
private async Task SyncClockAsync(RpcSession<ConnectionSession> rpc, ClockEstimator clock, int rounds, CancellationToken ct)
{
for (int i = 0; i < rounds; i++)
{
long sent = _env.Clock.NowUs();
Result<ClockResult> reply = await rpc.RequestAsync(MoShiroMethods.Clock, new ClockParams { ProbeMonoUs = sent }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, ct);
if (reply.TryGet(out ClockResult? value, out _))
{
clock.Add(sent, value.AgentMonoUs, _env.Clock.NowUs());
}
}
}
/// <summary>切块(每 10 秒)和时钟同步(每 30 秒)。</summary>
private async Task MaintainAsync(RpcSession<ConnectionSession> rpc, ConnectionSession session, ClockEstimator clock, 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<ConnectionSession> BuildRouter() =>
new RpcRouter<ConnectionSession>(RpcSide.Dialer, MoShiroMethodTable.Table)
.On(
FleetMethods.StateSync,
FleetRpcContext.Default.StateSyncParams,
FleetRpcContext.Default.StateSyncResult,
async (session, sync, _) =>
{
var result = new StateSyncResult { AppliedVersion = sync.Version };
if (sync.Type == LineTaskSet.StateType)
{
LineTaskSet? set = sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet);
if (set is null)
{
return Result<StateSyncResult>.Fail(FleetErrors.InvalidParams, "line_task_set could not be parsed");
}
result.Rejected.AddRange(await session.ApplyAsync(set));
}
return Result<StateSyncResult>.Ok(result);
})
.On(
MoShiroMethods.TestPhase,
MoShiroJsonContext.Default.TestPhaseStartParams,
MoShiroJsonContext.Default.TestPhaseStartResult,
(session, start, _) => ValueTask.FromResult(Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = session.StartPhase(start) })))
.On(
MoShiroMethods.TestPhaseEnd,
MoShiroJsonContext.Default.TestPhaseEndParams,
MoShiroJsonContext.Default.TestPhaseEndResult,
async (session, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await session.EndPhaseAsync(end, ct) }))
.OnNotify(
MoShiroMethods.TestAbort,
MoShiroJsonContext.Default.TestAbortParams,
async (session, abort, _) => await session.AbortAsync(abort.TestId))
.OnNotify(
FleetMethods.OutboxAck,
FleetRpcContext.Default.OutboxAckParams,
(session, ack, _) =>
{
session.Outbox.Acknowledge(ack.Ids.Concat(ack.Duplicates).Select(Guid.Parse));
return ValueTask.CompletedTask;
});
}
/// <summary>一次会话内的运行状态:线路任务的积木、按需测试的阶段。方法由路由器在单个入站队列里顺序调用。</summary>
public sealed class ConnectionSession
{
private const int MaxBlocks = 16;
private readonly object _gate = new();
private readonly Dictionary<string, RunningBlock> _lineBlocks = [];
private readonly ConcurrentDictionary<uint, PhaseState> _phases = new();
public ConnectionSession(BlockEnv blockEnv, ClockEstimator clock, Outbox outbox, ConnectionEnv env)
{
BlockEnv = blockEnv;
Clock = clock;
Outbox = outbox;
Env = env;
}
public BlockEnv BlockEnv { get; }
public ClockEstimator Clock { get; }
public Outbox Outbox { get; }
public ConnectionEnv Env { get; }
public int RoleCount
{
get
{
lock (_gate)
{
return _lineBlocks.Count;
}
}
}
/// <summary>调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。</summary>
public async Task<List<RejectedItem>> ApplyAsync(LineTaskSet set)
{
var rejected = new List<RejectedItem>();
var wanted = new Dictionary<string, BlockSpec>();
foreach (LineTask task in set.Tasks)
{
BlockSpec? spec = BlockCatalog.ProbeKinds.Contains(task.Kind)
? BlockSpec.Create(task.Kind, task.Params, task.StreamId, task.Port, new FragmentScope(task.Id, null, null))
: null;
if (spec is null)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = BlockCatalog.ProbeKinds.Contains(task.Kind) ? "invalid_params" : "unsupported" });
}
else if (wanted.Count >= MaxBlocks)
{
rejected.Add(new RejectedItem { Id = task.Id.Value, Reason = "budget_exceeded" });
}
else
{
wanted[task.Id.Value] = spec;
}
}
var stopped = new List<RunningBlock>();
lock (_gate)
{
foreach ((string id, RunningBlock running) in _lineBlocks.ToList())
{
if (!wanted.TryGetValue(id, out BlockSpec? spec) || spec.Signature != running.Signature)
{
stopped.Add(running);
_lineBlocks.Remove(id);
}
}
foreach ((string id, BlockSpec spec) in wanted)
{
if (!_lineBlocks.ContainsKey(id))
{
_lineBlocks[id] = RunningBlock.Start(BlockCatalog.Find(spec.KindId)!.Dialer(BlockEnv, spec), spec.Signature);
}
}
}
// 被停止的积木把已经稳定的行交出去(刚发出、还在等回复的行不交:它们不是丢失,只是没有等到)
foreach (RunningBlock running in stopped)
{
await running.StopAsync();
running.Block.Flush(FlushMode.Settled);
}
return rejected;
}
public void FlushLineBlocks()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
}
foreach (RunningBlock running in blocks)
{
running.Block.Flush(FlushMode.Settled);
}
}
/// <summary>按需测试的一个阶段:起对应的拨号方积木(单向数据流是接收端)。超过本地上限(或不认识)则拒绝。</summary>
public bool StartPhase(TestPhaseStartParams start)
{
string? direction = start.Params.TryGetValue("direction", out System.Text.Json.JsonElement d) ? d.GetString() : null;
if (_phases.ContainsKey(start.StreamId) || !BlockCatalog.ProbeKinds.Contains(start.Kind) || BlockCatalog.Find(start.Kind)!.Supports(direction) is false)
{
return false;
}
if (start.Kind == "udp_train")
{
double mbps = start.Params["mbps"].GetDouble();
long size = start.Params["size"].GetInt64();
long pps = (long)(mbps * 1_000_000 / 8 / Math.Max(1, size));
if (mbps > Env.Limits.MaxTestMbps || pps > Env.Limits.MaxTotalPps)
{
return false;
}
}
BlockSpec? spec = BlockSpec.Create(start.Kind, start.Params, start.StreamId, start.Port, new FragmentScope(null, start.TestId, start.Phase));
if (spec is null)
{
return false;
}
_phases[start.StreamId] = new PhaseState(start.TestId, start.Kind, RunningBlock.Start(BlockCatalog.Find(start.Kind)!.Dialer(BlockEnv, spec), spec.Signature));
return true;
}
/// <summary>阶段结束:收尾、交出列块,返回块数。</summary>
public async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
{
if (!_phases.TryRemove(end.StreamId, out PhaseState? phase))
{
return 0;
}
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "udp_train" ? 200 : 300), BlockEnv.Time, ct);
await phase.Running.StopAsync();
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
}
public async Task AbortAsync(TestId test)
{
foreach ((uint stream, PhaseState phase) in _phases.ToList())
{
if (phase.Test == test && _phases.TryRemove(stream, out _))
{
await phase.Running.StopAsync();
}
}
}
public async Task StopAllAsync()
{
List<RunningBlock> blocks;
lock (_gate)
{
blocks = [.. _lineBlocks.Values];
_lineBlocks.Clear();
}
blocks.AddRange(_phases.Values.Select(p => p.Running));
_phases.Clear();
foreach (RunningBlock running in blocks)
{
await running.StopAsync();
}
}
private sealed record PhaseState(TestId Test, string Kind, RunningBlock Running);
}
@@ -1,116 +0,0 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Hosting;
using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>
/// Probe 要连接的 Agent 列表:由面板作为期望状态(<c>probe_agents</c>)直接下发;面板连不上时用上一次保存的 <c>agents.json</c>。
/// 列表变化时用 <see cref="ConfigStore"/> 写回 <c>agents.json</c>(这是配置,不是测量数据;只在变化时写,保护路由器的闪存)。
/// </summary>
public sealed class AgentDirectory
{
public const string FileName = "agents.json";
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
private readonly TrustAnchor _trust;
private List<KnownAgent> _agents = [];
private long _version;
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_store = store;
_log = log;
string cache = Path.Combine(configDirectory, FileName);
if (File.Exists(cache))
{
try
{
AgentsCache saved = NodeConfig.Load(cache, ProbeConfigContext.Default.AgentsCache);
_agents = saved.Agents;
_version = saved.Version;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or JsonException or InvalidOperationException)
{
_log.Warn("agents.json is unreadable, waiting for the panel", ("err", ex.Message));
}
}
}
/// <summary>列表变化(调和连接)。</summary>
public event Action? Changed;
public long Version
{
get
{
lock (_gate)
{
return _version;
}
}
}
public IReadOnlyList<KnownAgent> Agents
{
get
{
lock (_gate)
{
return [.. _agents];
}
}
}
/// <summary>应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。</summary>
public void Apply(ProbeAgentList result)
{
var next = new List<KnownAgent>();
_trust.Update(result.TrustChain);
foreach (AgentEndpoint agent in result.Agents)
{
List<int> 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<KnownAgent> a, List<KnownAgent> b) =>
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(KnownAgent a, KnownAgent b) =>
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.QuicPorts.SequenceEqual(b.QuicPorts) && a.PublicKey == b.PublicKey;
}
@@ -1,28 +0,0 @@
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.props" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>moshiro-probe</AssemblyName>
<RootNamespace>MoShiro.Probe</RootNamespace>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<StripSymbols>true</StripSymbols>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MoShiro.Backend.Tests" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\..\fleet\src\Fleet.Hosting\Fleet.Hosting.csproj" />
<ProjectReference Include="..\..\..\shared\MoShiro.Measurement\MoShiro.Measurement.csproj" />
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="Sdk.targets" Sdk="StuDev.AotAnywhere"
Condition="'$(UseAotAnywhere)' == 'true'" />
<Import Project="..\..\..\shared\MoShiro.Measurement\StaticMsQuic.targets"
Condition="'$(StaticMsQuic)' == 'true'" />
</Project>
-331
View File
@@ -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;
/// <summary>
/// Probe 的组装点:到面板的长连接(身份、Agent 列表、状态)+ 每个 Agent 一个测量连接(认证、重连、角色)。没有全局状态,测试可以在同一进程里启动多个。
/// Agent 列表变化时只调和连接:新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
/// </summary>
public sealed class ProbeApp : IDisposable
{
public const string Version = MoShiro.Protocol.BuildInfo.Version;
private readonly ConnectionEnv _env;
private readonly Dictionary<string, Running> _connections = [];
private readonly ConcurrentDictionary<string, long> _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;
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
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>(
ProbeAgentList.StateType,
MoShiroJsonContext.Default.ProbeAgentList,
(version, doc, _) =>
{
directory.Apply(doc);
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
});
var router = new RpcRouter<LinkContext>(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);
}
/// <summary>到面板的连接(测试和状态查看用)。</summary>
public FleetLink Link => _link;
public AgentDirectory Directory => _env.Directory;
/// <summary>某个 Agent 的会话是否已经建立(测试和状态查看用)。</summary>
public bool IsConnected(string agentId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected;
}
}
public AgentConnection? Connection(string agentId, string ipVersion = IpVersions.V4)
{
lock (_connections)
{
return _connections.TryGetValue(agentId + "~" + ipVersion, out Running? running) ? running.Connection : null;
}
}
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<ProbeQuicLinkHealth> 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));
}
}
/// <summary>每隔几秒把主机资源占用的汇总数字发给面板,和 Agent 一样(没有型号、主机名、网卡名和 IP);链路断开时丢弃,重连之后下一次就补上。</summary>
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<KnownAgent> agents, CancellationToken ct)
{
var wanted = agents.ToDictionary(a => a.PathKey);
var stopped = new List<Running>();
lock (_connections)
{
foreach ((string id, Running running) in _connections.ToList())
{
if (!wanted.TryGetValue(id, out KnownAgent? endpoint) || !AgentDirectory.Equal(endpoint, running.Connection.Endpoint))
{
stopped.Add(running);
_connections.Remove(id);
}
}
}
foreach (Running running in stopped)
{
await running.StopAsync();
}
if (ct.IsCancellationRequested)
{
return;
}
lock (_connections)
{
foreach ((string id, KnownAgent endpoint) in wanted)
{
if (!_connections.ContainsKey(id))
{
_connections[id] = Running.Start(new AgentConnection(endpoint, _env));
}
}
}
}
private sealed class Running : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private Task _run = Task.CompletedTask;
private Running(AgentConnection connection)
{
Connection = connection;
}
public AgentConnection Connection { get; }
public static Running Start(AgentConnection connection)
{
var running = new Running(connection);
running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None);
return running;
}
public async Task StopAsync()
{
await _cts.CancelAsync();
try
{
await _run.WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// 没有及时退出:不再等
}
Dispose();
}
public void Dispose()
{
Connection.Dispose();
_cts.Dispose();
}
}
}
@@ -1,98 +0,0 @@
using System.Text.Json.Serialization;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>
/// <c>probe.json</c>:本地配置,和 Agent 一样由 <c>fleet-enroll</c> 注册之后写出身份目录(节点自己生成的两把密钥),这里只放面板地址和本地硬上限。
/// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。
/// </summary>
public sealed class ProbeFile
{
[JsonPropertyName("fleet")]
public ProbeFleetSection Fleet { get; set; } = new();
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。</summary>
[JsonPropertyName("restart_at")]
public string? RestartAt { get; set; }
[JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new();
/// <summary>上报主机资源占用,和 Agent 一样(只有汇总数字,没有型号和 IP);默认开,设 <c>host_stats: false</c> 就一个字也不上报。</summary>
[JsonPropertyName("reporting")]
public ReportingSection Reporting { get; set; } = new();
}
public sealed class ProbeFleetSection
{
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
[JsonPropertyName("server")]
public string Server { get; set; } = "";
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>(Agent 和 Probe 共用)。</summary>
[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<int> QuicPorts { get; set; } = [7443];
[JsonPropertyName("udp_ports")]
public List<int>? LegacyUdpPorts { get; set; }
[JsonIgnore]
public IReadOnlyList<int> EffectiveQuicPorts => LegacyUdpPorts ?? QuicPorts;
/// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
}
public sealed class ProbeLimits
{
/// <summary>按需测试里单个阶段允许的最大码率(Mbit/s)。</summary>
[JsonPropertyName("max_test_mbps")]
public double MaxTestMbps { get; set; } = 100;
/// <summary>按需测试里单个阶段允许的最大包速率。</summary>
[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;
/// <summary><c>agents.json</c>:最近一次从面板同步到的 Agent 列表(配置,不是测量数据);面板连不上时 Probe 靠它继续测量。</summary>
public sealed class AgentsCache
{
[JsonPropertyName("version")]
public long Version { get; set; }
[JsonPropertyName("agents")]
public List<KnownAgent> Agents { get; set; } = [];
}
@@ -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;
@@ -3,7 +3,7 @@ using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 一种采集方法(Kind)的两个角色:拨号方(Probe)和监听方(Agent),以及它能不能作为目标任务(Agent 自己探测一个地址,没有对端)运行。
/// 一种采集方法(Kind)的两个角色:拨号方和监听方,以及它能不能作为目标任务(节点自己探测一个地址,没有对端)运行。
/// 积木只按机制命名、只产生原始列,不知道上层的方案或协议。
/// </summary>
public sealed record BlockPair(
@@ -19,7 +19,7 @@ public sealed record BlockPair(
}
/// <summary>
/// 积木目录:新增一种采集方法 = 在 <see cref="Kinds"/> 里加一条声明,在这里加一行,实现它的两个角色。Probe 和 Agent 同时获得。
/// 积木目录:新增一种采集方法 = 在 <see cref="Kinds"/> 里加一条声明,在这里加一行,实现它的两个角色。所有节点同时获得。
/// hello 里上报的 capabilities、本地预算检查的支持列表都从这里推出来,不再手写。
/// </summary>
public static class BlockCatalog
@@ -37,9 +37,12 @@ public static class BlockCatalog
public static BlockPair? Find(string kind) => All.FirstOrDefault(p => p.Kind == kind);
/// <summary>这个构建的 Agent 能运行的 Kind(监听方,或目标任务)。</summary>
/// <summary>不监听的节点能运行的 Kind:只有目标任务(节点自己探测一个地址)。</summary>
public static IReadOnlyList<string> TargetKinds { get; } = [.. All.Where(p => p.RunsAsTarget).Select(p => p.Kind)];
/// <summary>监听的节点能运行的 Kind(监听方,或目标任务)。</summary>
public static IReadOnlyList<string> AgentKinds { get; } = [.. All.Where(p => p.Listener is not null || p.RunsAsTarget).Select(p => p.Kind)];
/// <summary>这个构建的 Probe 能运行的 Kind(有监听方可以配对的拨号方)。</summary>
public static IReadOnlyList<string> ProbeKinds { get; } = [.. All.Where(p => p.Listener is not null).Select(p => p.Kind)];
/// <summary>这个构建能作为拨号方运行的 Kind(有监听方可以配对的拨号方)。</summary>
public static IReadOnlyList<string> DialerKinds { get; } = [.. All.Where(p => p.Listener is not null).Select(p => p.Kind)];
}
@@ -56,8 +56,8 @@ internal static class BulkIo
}
/// <summary>
/// 批量传输,拨号方(Probe):开 <c>streams</c> 条 TCP 连接(可选 TLS),按 <c>direction</c> 发送(fwd)或接收(rev),持续 <c>duration_s</c>。
/// 记录每个采样间隔每条连接的<b>累计字节数</b>(<c>bytes</c> 流,Probe 的时钟),Backend 据此求每个间隔的吞吐;
/// 批量传输,拨号方:开 <c>streams</c> 条 TCP 连接(可选 TLS),按 <c>direction</c> 发送(fwd)或接收(rev),持续 <c>duration_s</c>。
/// 记录每个采样间隔每条连接的<b>累计字节数</b>(<c>bytes</c> 流,拨号方 的时钟),Backend 据此求每个间隔的吞吐;
/// 同时采样第一条连接自己这一侧的内核 <c>tcp_info</c>(多条连接的累计计数器不能混在同一个序列里求差)。
/// 发送速率不超过本地上限 <see cref="BlockEnv.MaxBulkMbps"/>。
/// </summary>
@@ -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
}
/// <summary>
/// 批量传输,监听方(Agent):接受连接,<c>rev</c> 时向 Probe 发送(受本地码率上限约束),<c>fwd</c> 时接收并丢弃;
/// 批量传输,监听方:接受连接,<c>rev</c> 时向 拨号方 发送(受本地码率上限约束),<c>fwd</c> 时接收并丢弃;
/// 采样第一条连接自己这一侧的内核 <c>tcp_info</c>(Agent 的重传反映下行方向,代理场景里大流量走这个方向)。
/// 连接在 <c>duration_s</c> 之后(加几秒余量)或阶段结束时关闭。
/// </summary>
@@ -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;
@@ -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
}
/// <summary>
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
/// UDP 回显,拨号方:按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
/// </summary>
public sealed class DatagramEchoDialer : BlockBase
{
@@ -163,7 +163,7 @@ public sealed class DatagramEchoDialer : BlockBase
}
}
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
/// <summary>UDP 回显,监听方:校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
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
@@ -19,7 +19,7 @@ public sealed class IdleListener : BlockBase
/// <summary>
/// 反复建立连接(TCP,可选 TLS)并记录每个阶段的耗时和结果。结果编码与 Backend 的 <c>ConnectOutcome</c> 一致:
/// 0 成功、1 超时、2 被重置或拒绝、3 TLS 告警、4 其他。拨号方是 Probe 时对端是 Agent(按指纹固定证书,建好连接即关闭);
/// 0 成功、1 超时、2 被重置或拒绝、3 TLS 告警、4 其他。拨号方连接监听方时(按指纹固定证书,建好连接即关闭);
/// 作为目标任务在 Agent 上运行时对端是任意地址(不校验证书,只测握手耗时)。
/// </summary>
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");
@@ -7,7 +7,7 @@ namespace MoShiro.Measurement;
/// <summary>
/// 单向数据流(UDP train),监听方(Agent,行的主导方):按声明的码率均匀发包,发出的每个包 (seq, size, t_send) 记在按序号的记录里,
/// Probe 的接收时刻之后按序号合并(没有到达的就是丢失)。发送在专用线程上完成,不占用线程池。
/// 拨号方 的接收时刻之后按序号合并(没有到达的就是丢失)。发送在专用线程上完成,不占用线程池。
/// 等不到对端的地址(5 秒内没有任何认证过的包)就以 <see cref="BlockFaultException"/> 结束。
/// </summary>
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
}
/// <summary>
/// 单向数据流,拨号方(Probe,行的跟随方):接收,记录每个序号第一次到达的时刻(按序号索引,热路径不分配);
/// 单向数据流,拨号方(行的跟随方):接收,记录每个序号第一次到达的时刻(按序号索引,热路径不分配);
/// 序号超过本地包速率上限推算出的范围时忽略。阶段结束时按序号范围切成多个列块交给 Agent,每块不超过 <see cref="ProtocolLimits.MaxRecordsPerChunk"/> 的记录。
/// 开始时先发几个探活包,让 Agent 学到我们当前的源地址(NAT 映射)再开始发送。
/// </summary>
@@ -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)
@@ -51,7 +51,7 @@ public interface IDataSessions
DataSession? Find(long sessionId);
}
/// <summary>只有一个会话(拨号方:Probe 与某个 Agent 的会话)。</summary>
/// <summary>只有一个会话(拨号方:与某个监听方的会话)。</summary>
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);
/// <summary>
/// 积木交出一侧的行的地方。Probe:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。
/// 积木交出一侧的行的地方。拨号方:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。
/// </summary>
public interface IFragmentSink
{
@@ -139,7 +139,7 @@ public sealed class BlockFaultException : Exception
}
/// <summary>
/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方 = Probe,监听方 = Agent),写在同一个文件里,
/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方和监听方),写在同一个文件里,
/// 共用包的构造、校验、行的记录;它们只产生<b>原始列</b>,不做任何统计,也不知道上层的方案或协议。
/// </summary>
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()));
/// <summary>参数、端口或流编号变化时重启:Probe 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。</summary>
/// <summary>参数、端口或流编号变化时重启:拨号方 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。</summary>
public string Signature => StreamId + "|" + ConfigSignature;
/// <summary>校验参数(补全默认值)后构造;Kind 不存在或参数非法返回 null。</summary>
@@ -216,7 +216,7 @@ public sealed class BlockEnv
public required INodeLog Log { get; init; }
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 拨号方 会话,可能没有)。</summary>
public required Func<DataSession?> Session { get; init; }
public QuicDatagramTransport? Datagrams { get; init; }
@@ -249,7 +249,7 @@ public sealed class BlockEnv
public TimeProvider Time => Clock.Time;
/// <summary>同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 Probe 会话各起一组监听方积木)。</summary>
/// <summary>同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 拨号方 会话各起一组监听方积木)。</summary>
public BlockEnv WithSession(DataSession session) =>
new()
{
@@ -1,6 +1,6 @@
namespace MoShiro.Measurement;
/// <summary>节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;Probe 和 Agent 各自一个,块头里的时钟锚点把它对应到 UTC。</summary>
/// <summary>节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;每个节点各一个,块头里的时钟锚点把它对应到 UTC。</summary>
public sealed class MonoClock
{
private readonly TimeProvider _time;
@@ -4,8 +4,8 @@ namespace MoShiro.Measurement;
/// <summary>
/// 一侧记录的行:只含这一侧拥有的列(见 <see cref="StreamSpec.ColumnsOf"/>),按连接键(通常是 <c>seq</c>)索引的环形缓冲,
/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 Probe 的列块到达后按键合并;
/// Probe 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。
/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 拨号方 的列块到达后按键合并;
/// 拨号方 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。
/// </summary>
public sealed class RowStore
{
@@ -65,7 +65,7 @@ public sealed class RowStore
}
/// <summary>
/// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:Probe 重启之后序号从 0 重新开始,
/// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:拨号方 重启之后序号从 0 重新开始,
/// 旧会话里同一个序号的 t2/t3 还留着的话,会被当成新会话里这个包"上行已经到达",真正的上行丢失就被算成了下行丢失。
/// </summary>
public void Clear()
@@ -1,6 +1,6 @@
namespace MoShiro.Measurement;
/// <summary>一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。</summary>
/// <summary>一个运行中的积木:自己的取消令牌和后台任务。拨号方和监听方共用。</summary>
public sealed class RunningBlock : IDisposable
{
private readonly CancellationTokenSource _cts = new();
@@ -5,11 +5,11 @@ using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>Agent 单调时钟(微秒)与 UTC(毫秒)的对应关系:块头里的时钟锚点。</summary>
public readonly record struct ClockAnchor(long AgentMonoUs, long UtcMs);
public readonly record struct ClockAnchor(long ListenerMonoUs, long UtcMs);
/// <summary>
/// 给合并好的流盖章:生成完整的块头(任务/测试、参数快照、端口、时钟锚点、起止时间)。
/// 起止时间由数据本身的时间戳换算得出,而不是取收到列块的时刻,所以不受 Probe 交付延迟的影响。
/// 起止时间由数据本身的时间戳换算得出,而不是取收到列块的时刻,所以不受拨号方交付延迟的影响。
/// </summary>
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<string, JsonElement>(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));
}
}
@@ -11,10 +11,10 @@ public interface IRecordSource
}
/// <summary>
/// 把 Probe 一侧的列块(一个只带 Probe 列的 RawChunk)与 Agent 一侧的记录合并成完整的行。合并完全由 Kind 的 <see cref="StreamSpec"/> 声明驱动:
/// 把 拨号方 一侧的列块(一个只带 拨号方 列的 RawChunk)与 Agent 一侧的记录合并成完整的行。合并完全由 Kind 的 <see cref="StreamSpec"/> 声明驱动:
/// <list type="bullet">
/// <item><description>声明了连接键、两侧都有列:按键连接。主导方是 Probe(回显)时行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
/// 主导方是 Agent(单向数据流)时行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失)。</description></item>
/// <item><description>声明了连接键、两侧都有列:按键连接。主导方是 拨号方(回显)时行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
/// 主导方是 Agent(单向数据流)时行取自 Agent 的记录(按列块头里的序号范围),拨号方 的列按键补上,缺失就是缺失(丢失)。</description></item>
/// <item><description>没有连接键,或者只有主导方一侧有列:原样透传,只按声明的列顺序重排。</description></item>
/// </list>
/// 合并之后的流按声明的列顺序排列,所以 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<IReadOnlyList<RawStream>>.Ok(streams);
}
private static Result<RawStream> Join(StreamSpec spec, RawStream probe, RawChunkHeader header, RowStore? agent)
private static Result<RawStream> Join(StreamSpec spec, RawStream fragment, RawChunkHeader header, RowStore? local)
{
if (spec.Union)
{
return Result<RawStream>.Ok(Concatenate(spec, probe, agent));
return Result<RawStream>.Ok(Concatenate(spec, fragment, local));
}
IReadOnlyList<ColumnSpec> probeColumns = spec.ColumnsOf(Side.Probe);
IReadOnlyList<ColumnSpec> agentColumns = spec.ColumnsOf(Side.Agent);
if (spec.Key is null || (spec.Driver == Side.Probe && agentColumns.Count == 0))
IReadOnlyList<ColumnSpec> dialerColumns = spec.ColumnsOf(Side.Dialer);
IReadOnlyList<ColumnSpec> listenerColumns = spec.ColumnsOf(Side.Listener);
if (spec.Key is null || (spec.Driver == Side.Dialer && listenerColumns.Count == 0))
{
return Result<RawStream>.Ok(Conform(spec, probe));
return Result<RawStream>.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<RawStream>.Fail(MoShiroErrors.ValidationFailed, $"a fragment of stream {spec.Name} needs the columns {string.Join(", ", new[] { spec.Key }.Concat(probeColumns.Select(c => c.Name)))}");
return Result<RawStream>.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<RawStream> JoinOnProbeRows(StreamSpec spec, RawStream probe, RawColumn key, RowStore? agent)
private static Result<RawStream> JoinOnDialerRows(StreamSpec spec, RawStream fragment, RawColumn key, RowStore? local)
{
int n = probe.Count;
int n = fragment.Count;
var columns = new List<RawColumn>(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<RawStream>.Ok(new RawStream(spec.Name, n, columns));
}
private static Result<RawStream> JoinOnAgentRows(StreamSpec spec, RawStream probe, RawColumn key, RawChunkHeader header, RowStore? agent)
private static Result<RawStream> JoinOnListenerRows(StreamSpec spec, RawStream fragment, RawColumn key, RawChunkHeader header, RowStore? local)
{
if (!TryGetRange(header, out uint from, out uint to))
{
return Result<RawStream>.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<long, int>();
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
}
/// <summary>
/// 两侧各自记录完整行的流(例如各自套接字的内核采样):Probe 的行(列块里)加上 Agent 自己新记录的行,按声明的列顺序拼接。
/// 两侧各自记录完整行的流(例如各自套接字的内核采样):拨号方 的行(列块里)加上 Agent 自己新记录的行,按声明的列顺序拼接。
/// 每一行里有一列标明它属于哪一侧,所以不需要连接。
/// </summary>
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);
}
/// <summary>按声明的列顺序重排;Probe 一侧已经是完整的行时不改变内容。</summary>
/// <summary>按声明的列顺序重排;拨号方 一侧已经是完整的行时不改变内容。</summary>
private static RawStream Conform(StreamSpec spec, RawStream stream)
{
var columns = new List<RawColumn>(spec.Columns.Count);
@@ -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);
@@ -1,6 +1,6 @@
namespace MoShiro.Measurement;
/// <summary>The application protocol negotiated by the Agent and Probe QUIC endpoints.</summary>
/// <summary>The application protocol negotiated by the Agent and 拨号方 QUIC endpoints.</summary>
internal static class QuicAlpn
{
internal static readonly byte[] Value = "h3"u8.ToArray();
@@ -6,7 +6,7 @@ using Fleet.Core;
namespace MoShiro.Measurement;
/// <summary>
/// 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.
/// </summary>
@@ -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;
}
/// <summary>Constructs a Probe-side QUIC dialer for one Agent session.</summary>
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, int previousSourcePort)
/// <summary>Constructs a 拨号方-side QUIC dialer for one Agent session.</summary>
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<int> BoundPorts { get; private set; } = [];
@@ -79,33 +78,39 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
BoundPorts = _listener.BoundPorts;
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>Completes when the current 拨号方-side connection has shut down (peer close, idle timeout, local close).</summary>
public Task WaitForDisconnectAsync(CancellationToken ct) =>
_client is { } client ? client.WaitForShutdownAsync().WaitAsync(ct) : Task.CompletedTask;
/// <summary>Closes the current 拨号方-side connection; the next <see cref="ConnectAsync"/> opens a new one.</summary>
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;
}
/// <summary>Agent side: holds the listener open until cancelled. 拨号方-side connections are driven by <see cref="ConnectAsync"/> and <see cref="WaitForDisconnectAsync"/>.</summary>
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)
{
@@ -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();
@@ -71,7 +71,7 @@ public static class TcpInfo
/// <summary>
/// 一条连接上周期性的内核采样(<c>tcp_info</c> 流):每个采样一行 (ts, side, srtt, rttvar, cwnd, retrans_total, segs_out, bytes_acked, conn)。
/// 两端各采样自己的套接字:Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。
/// 两端各采样自己的套接字:拨号方 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。
/// 累计计数器在 Backend 按 (side, conn) 分别求差;conn 是当前记录器内分配的连接编号,不包含网络地址。
/// </summary>
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; }
@@ -4,7 +4,7 @@ using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 拨号方和监听方共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
namespace MoShiro.Measurement;
/// <summary>节点本地配置里的 <c>reporting</c> 段(面板无法修改):Agent 和 Probe 共用。</summary>
/// <summary>节点本地配置里的 <c>reporting</c> 段(面板无法修改):拨号方和监听方共用。</summary>
public sealed class ReportingSection
{
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
@@ -23,37 +23,37 @@ public enum ColumnEnc
Delta,
}
/// <summary>时间列的时钟归属:Probe、Agent 各自的单调时钟(微秒),或所在一侧的本地时钟。</summary>
/// <summary>时间列的时钟归属:拨号方、监听方各自的单调时钟(微秒),或所在一侧的本地时钟。</summary>
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);
/// <summary>数据面上的一侧:Probe 永远是拨号方,Agent 永远是监听方。</summary>
/// <summary>数据面上的一侧:拨号方发起连接,监听方接受连接。</summary>
public enum Side
{
Probe,
Agent,
Dialer,
Listener,
}
/// <summary>
/// 一个记录流(一张表)。<see cref="Driver"/> 是行的主导方:行由它发出的包或事件决定,另一侧的列按 <see cref="Key"/>(例如 <c>seq</c>)补充,
/// 缺失就是缺失(回显缺 t2/t3 表示上行丢失,单向数据流缺 t_recv 表示丢失)。没有 <see cref="Key"/> 的流只有主导方一侧的列,原样透传。
/// 每一列属于哪一侧由它的时钟决定(<c>TsProbe</c> 属于 Probe,<c>TsAgent</c> 属于 Agent),其他列属于主导方。
/// 每一列属于哪一侧由它的时钟决定(<c>TsDialer</c> 属于拨号方,<c>TsListener</c> 属于监听方),其他列属于主导方。
/// <see cref="Union"/> 流是例外:两侧各自记录完整的行(行里有一列标明是哪一侧),合并时直接拼接,不连接。
/// 这一条声明同时决定了两端各记录什么、Agent 怎么合并,新增一种记录流不需要写合并代码。
/// 这一条声明同时决定了两端各记录什么、监听方怎么合并,新增一种记录流不需要写合并代码。
/// </summary>
public sealed record StreamSpec(string Name, IReadOnlyList<ColumnSpec> Columns, Side Driver = Side.Probe, string? Key = null, bool Union = false)
public sealed record StreamSpec(string Name, IReadOnlyList<ColumnSpec> 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
/// <summary>
/// 采集方法声明:积木组合 + 参数 + 记录的列 + 带宽预算。
/// 这一条声明同时被 Probe/Agent(组装执行)、Backend(校验与目录)、Analysis(按列名读数据)使用。
/// 这一条声明同时被拨号方和监听方(组装执行)、Backend(校验与目录)、Analysis(按列名读数据)使用。
/// </summary>
public sealed record KindSpec(
string Id,
@@ -109,9 +109,9 @@ public static class Col
/// <summary>单调递增的序号:差分编码。</summary>
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);
}
@@ -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", []),
[
@@ -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<ProbeId>
{
protected override ProbeId Create(string value) => new(value);
protected override string GetValue(ProbeId id) => id.Value;
}
public sealed class TaskIdJsonConverter : StringIdJsonConverter<TaskId>
{
protected override TaskId Create(string value) => new(value);
@@ -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" : "");
}
@@ -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");
@@ -30,7 +30,7 @@ public static class MoShiroChannels
/// <summary>Agent → Backend:RawChunk(发件箱)。</summary>
public const byte RawChunk = 16;
/// <summary>Probe → Agent:列块(点对点会话的发件箱)。</summary>
/// <summary>拨号方 → Agent:列块(点对点会话的发件箱)。</summary>
public const byte ColumnBlock = 17;
}
+16 -16
View File
@@ -4,22 +4,22 @@ using Fleet.Core;
namespace MoShiro.Protocol;
/// <summary>Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。</summary>
/// <summary>监听方单调时钟与 UTC 的锚点,以及拨号方时钟相对监听方时钟的偏移估计。</summary>
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; }
/// <summary>Agent 时钟 − Probe 时钟(微秒)。</summary>
[JsonPropertyName("probe_offset_us")]
public long ProbeOffsetUs { get; set; }
/// <summary>监听方时钟 − 拨号方时钟(微秒)。</summary>
[JsonPropertyName("dialer_offset_us")]
public long DialerOffsetUs { get; set; }
/// <summary>Probe 相对 Agent 的时钟漂移(ppb,十亿分之一)。</summary>
[JsonPropertyName("probe_drift_ppb")]
public long ProbeDriftPpb { get; set; }
/// <summary>拨号方相对监听方的时钟漂移(ppb,十亿分之一)。</summary>
[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; }
/// <summary>ts_probe | ts_agent | ts_local,其他为空。</summary>
/// <summary>ts_dialer | ts_listener | ts_local,其他为空。</summary>
[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; }
/// <summary>Probe 一侧的列没有在宽限期内送达:只有 Agent 一侧的数据。</summary>
/// <summary>拨号方一侧的列没有在宽限期内送达:只有监听方一侧的数据。</summary>
[JsonPropertyName("partial")]
public bool Partial { get; set; }
@@ -116,11 +116,11 @@ public sealed class RawChunkHeader
public List<StreamHeader> Streams { get; set; } = [];
/// <summary>
/// "line:&lt;probe_id&gt;" 或 "target:&lt;task_id&gt;":存储和分析用的序列标识。
/// 同一个 Agent 上每个 Probe 的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。
/// "line:&lt;dialer_id&gt;" 或 "target:&lt;task_id&gt;":存储和分析用的序列标识。
/// 同一个监听节点上每个拨号方的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。
/// </summary>
[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:";
}
@@ -6,7 +6,7 @@ using Fleet.Core;
namespace MoShiro.Protocol;
/// <summary>
/// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。
/// RawChunk 的编解码(拨号方、Agent、Backend、离线工具共用同一份代码)。
/// 布局:<c>"MSRC" | u8 container_version | u32 crc32c(压缩数据) | Brotli( u32 header_len | header JSON | 各列数据 )</c>。
/// 列数据:可空列先写有效位图,再写有效值;plain 为无符号 varint(有符号类型用 zigzag),delta 为与上一个有效值之差的 zigzag varint。
/// </summary>
@@ -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,
};
@@ -2,23 +2,12 @@ using Fleet.Rpc;
namespace MoShiro.Protocol;
/// <summary>节点的角色。服务端按节点 ID 的前缀判断;声明表用它限定哪些角色可以参与某个方法或通道。</summary>
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;
}
/// <summary>
/// 所有方法和二进制通道的方向声明,单一真源(框架的 <c>fleet.*</c> 见 <see cref="FleetMethodTable"/>)。
/// 新增一个方法:在 <see cref="MoShiroMethods"/> 里加名字和参数,在这里加一行声明,再在对应一侧注册处理函数;
/// 声明了但没人处理、或者处理了但没声明,都会被测试和启动时的检查发现。
/// 同一个方法名只有一个发起方;节点面是服务端和节点(B 和 A、P),对等面是 Probe(拨号方)和 Agent(监听方)。
/// 同一个方法名只有一个发起方;节点面是服务端和节点,对等面是拨号方和监听方。所有节点是同一种角色:
/// 能做什么由节点的配置(是否监听)和 Backend 下发的期望状态决定,声明表不区分节点类型。
/// </summary>
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),
]));
}
+206 -162
View File
@@ -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<string> 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; }
/// <summary>在哪个 Probe 上运行:必须是当前连着这个 Agent 的 Probe。</summary>
[JsonPropertyName("probe_id")]
public ProbeId ProbeId { get; set; }
/// <summary>在哪个拨号方上运行:必须是当前连着这个监听方的节点。</summary>
[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; }
}
/// <summary>Backend → Agent:断开某个 Probe 的会话(Probe 会按退避重连,除非它已经被吊销)。</summary>
public sealed class ProbeDisconnectParams
/// <summary>Backend → 监听方:断开某个拨号方的会话(拨号方按退避重连,除非它已经被吊销)。</summary>
public sealed class DialerDisconnectParams
{
[JsonPropertyName("probe_id")]
public ProbeId ProbeId { get; set; }
[JsonPropertyName("dialer_id")]
public NodeId DialerId { get; set; }
}
/// <summary>Backend → Probe: immediately retry the selected Agent's current IP paths.</summary>
public sealed class ProbeLinkRetryParams
/// <summary>Backend → 拨号方:立即重试到指定监听方的线路。</summary>
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
{
/// <summary><c>probe_connected</c> 时:这个 Probe 的 id、版本、地址和连接时间(Backend 不用等下一次心跳就能显示)。</summary>
[JsonPropertyName("probe")]
public ConnectedProbe? Probe { get; set; }
/// <summary><c>dialer_connected</c> 时:这个拨号方的 id、版本、地址和连接时间(Backend 不用等下一次心跳就能显示)。</summary>
[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
/// <summary>拨号目标:一个监听节点的地址、端口和固定证书用的公钥。</summary>
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<int> QuicPorts { get; set; } = [];
[JsonPropertyName("udp_ports")]
public List<int> LegacyUdpPorts { get; set; } = [];
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
/// <summary>监听方面向拨号方的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和拨号方的身份密钥派生。</summary>
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
}
/// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
public sealed class ProbeAgentList
/// <summary>期望状态文档(Fleet state type = <c>dial_targets</c>):Backend 下发给每个节点的、它要拨出的监听节点列表。</summary>
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<AgentEndpoint> Agents { get; set; } = [];
[JsonPropertyName("targets")]
public List<DialTarget> Targets { get; set; } = [];
[JsonPropertyName("trust_chain")]
public List<SignedStatementDto>? TrustChain { get; set; }
}
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
public sealed class AgentEnrollMeta
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。不监听的节点不带端口。</summary>
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<int> ProbeQuicPorts { get; set; } = [];
[JsonPropertyName("probe_udp_ports")]
public List<int>? LegacyProbeUdpPorts { get; set; }
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [];
[JsonPropertyName("public_host")]
public string? PublicHost { get; set; }
@@ -307,19 +302,39 @@ public sealed class UpdateStatus
public long At { get; set; }
}
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
public sealed class ProbeHeartbeatExtra
/// <summary>节点的 <c>fleet.heartbeat</c> 附加字段:进程内的计数、拨号侧的线路健康和监听侧的会话快照,没有主机信息。</summary>
public sealed class NodeHeartbeatExtra
{
/// <summary>Node operating state; registration approval and transport presence are reported separately.</summary>
[JsonPropertyName("runtime_status")]
public NodeRuntimeStatus RuntimeStatus { get; set; } = NodeRuntimeStatus.Running;
[JsonPropertyName("quic_links")]
public List<ProbeQuicLinkHealth>? QuicLinks { get; set; }
/// <summary>拨号侧:到每个监听节点(每个 IP 族)的线路健康。</summary>
[JsonPropertyName("links")]
public List<LinkHealth>? Links { get; set; }
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
/// <summary>监听侧:当前连着的拨号方,每次心跳都是完整的快照,Backend 靠它纠正错过的 dialer_connected / dialer_disconnected 事件。</summary>
[JsonPropertyName("dialers")]
public List<ConnectedDialer> 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; }
}
/// <summary>Probe's rolling QUIC connection health snapshot for one Agent/IP path.</summary>
public sealed class ProbeQuicLinkHealth
/// <summary>
/// 拨号方到一个监听方(一个 IP 族)的线路健康。控制层(TCP+TLS 长连接,承载控制 RPC 和回传数据)与任务层(测量用的 TCP / QUIC)
/// 各有一个状态机:控制层断开才算两个节点失联,任务层只影响测量。
/// </summary>
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();
/// <summary>控制层在线且 QUIC 数据通道就绪:这条线路可以完整测量。</summary>
[JsonIgnore]
public bool Ready => Control.State == ControlState.Up && Task.Quic.State == QuicState.Ready;
}
/// <summary>某一层最近一次失败的原因:失败发生的阶段、分类和说明。</summary>
public sealed class LinkFault
{
[JsonPropertyName("phase")]
public string Phase { get; set; } = "";
/// <summary><c>timeout</c>、<c>refused</c>、<c>reset</c>、<c>dns</c>、<c>tls</c>、<c>auth</c>、<c>closed</c>、<c>silent</c>、<c>other</c>。</summary>
[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}";
}
/// <summary>控制层:到 Agent 的 TCP+TLS 长连接,包括认证、时钟同步,以及回传数据的发件箱。</summary>
public sealed class ControlHealth
{
[JsonPropertyName("state")]
public ProbeLinkAttemptState State { get; set; } = ProbeLinkAttemptState.Connecting;
public ControlState State { get; set; } = ControlState.Dialing;
/// <summary>进入当前状态的时间(Unix 毫秒)。</summary>
[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; }
/// <summary>发件箱里还没有被确认的回传数据。</summary>
[JsonPropertyName("outbox_bytes")]
public long OutboxBytes { get; set; }
[JsonPropertyName("outbox_pending")]
public int OutboxPending { get; set; }
}
/// <summary>任务层:测量自己使用的链路。TCP 类积木自带套接字和重拨,QUIC 数据通道由拨号方单独维护。</summary>
public sealed class TaskHealth
{
/// <summary>正在运行的 TCP 类线路积木数(tcp_echo、connect、hold、tcp_bulk)。</summary>
[JsonPropertyName("tcp_blocks")]
public int TcpBlocks { get; set; }
[JsonPropertyName("quic")]
public QuicHealth Quic { get; set; } = new();
}
/// <summary>QUIC 数据通道(udp_echo、udp_train 使用)的状态、握手统计和传输层统计。</summary>
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<ProbeLinkAttemptState>))]
public enum ProbeLinkAttemptState
/// <summary>控制层状态:<c>dialing</c>(DNS、TCP、敲门、TLS)→ <c>authenticating</c>(身份签名)→ <c>syncing</c>(时钟)→ <c>up</c>;失败进入 <c>backoff</c>。</summary>
[JsonConverter(typeof(JsonStringEnumConverter<ControlState>))]
public enum ControlState
{
[JsonStringEnumMemberName("dialing")]
Dialing,
[JsonStringEnumMemberName("authenticating")]
Authenticating,
[JsonStringEnumMemberName("syncing")]
Syncing,
[JsonStringEnumMemberName("up")]
Up,
[JsonStringEnumMemberName("backoff")]
Backoff,
}
/// <summary>QUIC 数据通道状态:控制层不在线时是 <c>idle</c>;<c>connecting</c> 握手中;<c>ready</c> 可发送;<c>retrying</c> 断开后等待重拨。</summary>
[JsonConverter(typeof(JsonStringEnumConverter<QuicState>))]
public enum QuicState
{
[JsonStringEnumMemberName("idle")]
Idle,
[JsonStringEnumMemberName("connecting")]
Connecting,
[JsonStringEnumMemberName("retry_wait")]
RetryWait,
[JsonStringEnumMemberName("connected")]
Connected,
[JsonStringEnumMemberName("ready")]
Ready,
[JsonStringEnumMemberName("retrying")]
Retrying,
}
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
public sealed class ProbeHelloMeta
/// <summary>节点的 <c>fleet.hello</c> 附加字段:属主、系统与构建信息,以及(监听时)面向拨号方的接入信息。</summary>
public sealed class NodeHelloMeta
{
/// <summary>这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
/// <summary>这个节点的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
@@ -412,44 +517,31 @@ public sealed class ProbeHelloMeta
[JsonPropertyName("platform")]
public PlatformInfo? Platform { get; set; }
[JsonPropertyName("local_limits")]
public LocalLimits? LocalLimits { get; set; }
/// <summary>监听接入信息;节点不监听时为 null,面板不会把它当作拨号目标。</summary>
[JsonPropertyName("listen")]
public ListenInfo? Listen { get; set; }
}
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
public sealed class AgentHelloMeta
/// <summary>节点作为监听方时,面向拨号方的接入信息。</summary>
public sealed class ListenInfo
{
/// <summary>这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [];
/// <summary>本机是否启用了 IPv6 监听。旧 Agent 缺省为 false,面板不会给它安排 IPv6 会话。</summary>
/// <summary>本机是否启用了 IPv6 监听;面板只给启用了的节点安排 IPv6 会话。</summary>
[JsonPropertyName("ipv6_supported")]
public bool Ipv6Supported { get; set; }
[JsonPropertyName("platform")]
public PlatformInfo? Platform { get; set; }
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64);面板据此得到证书指纹,并下发给 Probe 用来固定证书和派生敲门密钥。</summary>
[JsonPropertyName("tls_pubkey")]
public string? TlsPublicKey { get; set; }
/// <summary>本机的公网地址(IPv4 公网地址、IPv6 全球单播地址):面板据此自动填 <c>public_host</c> 和 <c>ipv6_host</c>,管理员手填的优先。</summary>
[JsonPropertyName("addresses")]
public List<string>? Addresses { get; set; }
[JsonPropertyName("probe_tcp_port")]
public int? ProbeTcpPort { get; set; }
[JsonPropertyName("probe_quic_ports")]
public List<int>? ProbeQuicPorts { get; set; }
[JsonPropertyName("probe_udp_ports")]
public List<int>? LegacyProbeUdpPorts { get; set; }
[JsonPropertyName("local_limits")]
public LocalLimits? LocalLimits { get; set; }
}
/// <summary>
@@ -497,19 +589,19 @@ public sealed class HostSnapshot
public long AtUtcMs { get; set; }
}
/// <summary>Agent 眼中的一个已连接 Probe。</summary>
public sealed class ConnectedProbe
/// <summary>监听方眼中的一个已连接拨号方。</summary>
public sealed class ConnectedDialer
{
[JsonPropertyName("ip_version")]
public string IpVersion { get; set; } = IpVersions.V4;
[JsonPropertyName("id")]
public ProbeId Id { get; set; }
public NodeId Id { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
/// <summary>Probe 的远端地址(经过 NAT 之后的公网 IP:端口)。</summary>
/// <summary>拨号方的远端地址(经过 NAT 之后的公网 IP:端口)。</summary>
[JsonPropertyName("remote")]
public string? Remote { get; set; }
@@ -517,50 +609,7 @@ public sealed class ConnectedProbe
public long SinceUtcMs { get; set; }
}
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
public sealed class AgentHeartbeatExtra
{
/// <summary>Node operating state; registration approval and transport presence are reported separately.</summary>
[JsonPropertyName("runtime_status")]
public NodeRuntimeStatus RuntimeStatus { get; set; } = NodeRuntimeStatus.Running;
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
/// <summary>当前连着的 Probe 会话数(等于 <see cref="Probes"/> 的个数)。</summary>
[JsonPropertyName("sessions")]
public int Sessions { get; set; }
/// <summary>当前连着的 Probe:每次心跳都是完整的快照,Backend 靠它纠正错过的 probe_connected / probe_disconnected 事件。</summary>
[JsonPropertyName("probes")]
public List<ConnectedProbe> Probes { get; set; } = [];
[JsonPropertyName("pps_in")]
public long PpsIn { get; set; }
[JsonPropertyName("pps_out")]
public long PpsOut { get; set; }
[JsonPropertyName("dropped_invalid")]
public long DroppedInvalid { get; set; }
[JsonPropertyName("dropped_rate_limited")]
public long DroppedRateLimited { get; set; }
[JsonPropertyName("queue_bytes")]
public long QueueBytes { get; set; }
[JsonPropertyName("update")]
public UpdateStatus? Update { get; set; }
[JsonPropertyName("runtime_logs")]
public List<NodeLogRecord>? RuntimeLogs { get; set; }
[JsonPropertyName("dropped_runtime_logs")]
public long? DroppedRuntimeLogs { get; set; }
}
/// <summary>Agent 下发给 Probe 的一条线路任务(期望状态类型 <c>line_task_set</c> 里的一项)。</summary>
/// <summary>监听方下发给拨号方的一条线路任务(期望状态类型 <c>line_task_set</c> 里的一项)。</summary>
public sealed class LineTask
{
[JsonPropertyName("id")]
@@ -580,7 +629,7 @@ public sealed class LineTask
public int Port { get; set; }
}
/// <summary>这条线路的期望状态(Agent → Probe):Probe 按它调和自己一侧的角色。</summary>
/// <summary>这条线路的期望状态(监听方 → 拨号方):拨号方按它调和自己一侧的角色。</summary>
public sealed class LineTaskSet
{
public const string StateType = "line_task_set";
@@ -591,31 +640,28 @@ public sealed class LineTaskSet
[JsonPropertyName("tasks")]
public List<LineTask> Tasks { get; set; } = [];
/// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary>
/// <summary>监听方的数据面端口(拨号方把包发到这里)。</summary>
[JsonPropertyName("quic_ports")]
public List<int> QuicPorts { get; set; } = [];
[JsonPropertyName("udp_ports")]
public List<int> LegacyUdpPorts { get; set; } = [];
[JsonPropertyName("tcp_ports")]
public List<int> TcpPorts { get; set; } = [];
}
/// <summary><c>moshiro.clock</c>:Probe 用它估计与 Agent 单调时钟的偏移(对称往返假设)。</summary>
/// <summary><c>moshiro.clock</c>:拨号方用它估计与监听方单调时钟的偏移(对称往返假设)。</summary>
public sealed class ClockParams
{
[JsonPropertyName("probe_mono_us")]
public long ProbeMonoUs { get; set; }
[JsonPropertyName("dialer_mono_us")]
public long DialerMonoUs { get; set; }
}
public sealed class ClockResult
{
[JsonPropertyName("agent_mono_us")]
public long AgentMonoUs { get; set; }
[JsonPropertyName("listener_mono_us")]
public long ListenerMonoUs { get; set; }
}
/// <summary>按需测试的一个阶段(Agent → Probe):Agent 编排时间,Probe 准备好自己一侧的角色之后应答。</summary>
/// <summary>按需测试的一个阶段(监听方 → 拨号方):监听方编排时间,拨号方准备好自己一侧的角色之后应答。</summary>
public sealed class TestPhaseStartParams
{
[JsonPropertyName("test_id")]
@@ -646,7 +692,7 @@ public sealed class TestPhaseStartResult
public bool Accepted { get; set; }
}
/// <summary>阶段结束(Agent → Probe):Probe 收尾并把自己一侧的列块交给 Agent。<c>sent_count</c> 是 Agent 一侧发出的包数(单向数据流用)。</summary>
/// <summary>阶段结束(监听方 → 拨号方):拨号方收尾并把自己一侧的列块交给监听方。<c>sent_count</c> 是监听方一侧发出的包数(单向数据流用)。</summary>
public sealed class TestPhaseEndParams
{
[JsonPropertyName("test_id")]
@@ -662,7 +708,7 @@ public sealed class TestPhaseEndParams
public long SentCount { get; set; }
}
/// <summary>Probe 收尾之后回复:它交给 Agent 的列块个数(Agent 据此等待全部列块到齐再进入下一阶段)。</summary>
/// <summary>拨号方收尾之后回复:它交给监听方的列块个数(监听方据此等待全部列块到齐再进入下一阶段)。</summary>
public sealed class TestPhaseEndResult
{
[JsonPropertyName("fragments")]
@@ -675,7 +721,7 @@ public sealed class TestAbortParams
public TestId TestId { get; set; }
}
/// <summary>Probe 一侧的列块放在 RawChunk 头部 params 里的保留键:本块覆盖的序号范围 [from, to)(单向数据流由 Agent 一侧按它取行)。</summary>
/// <summary>拨号方一侧的列块放在 RawChunk 头部 params 里的保留键:本块覆盖的序号范围 [from, to)(单向数据流由监听方一侧按它取行)。</summary>
public static class FragmentKeys
{
public const string SeqFrom = "_seq_from";
@@ -695,21 +741,19 @@ public static class FragmentKeys
[JsonSerializable(typeof(TestRunParams))]
[JsonSerializable(typeof(TestRunResult))]
[JsonSerializable(typeof(TestCancelParams))]
[JsonSerializable(typeof(ProbeDisconnectParams))]
[JsonSerializable(typeof(ProbeLinkRetryParams))]
[JsonSerializable(typeof(DialerDisconnectParams))]
[JsonSerializable(typeof(LinkRetryParams))]
[JsonSerializable(typeof(HostSnapshot))]
[JsonSerializable(typeof(TestStatusParams))]
[JsonSerializable(typeof(AgentEventParams))]
[JsonSerializable(typeof(ProbeAgentList))]
[JsonSerializable(typeof(DialTargets))]
[JsonSerializable(typeof(UpdateOfferParams))]
[JsonSerializable(typeof(ScheduleParams))]
[JsonSerializable(typeof(UpdateStatus))]
[JsonSerializable(typeof(ProbeHeartbeatExtra))]
[JsonSerializable(typeof(ProbeHelloMeta))]
[JsonSerializable(typeof(AgentEnrollMeta))]
[JsonSerializable(typeof(NodeHeartbeatExtra))]
[JsonSerializable(typeof(NodeHelloMeta))]
[JsonSerializable(typeof(NodeEnrollMeta))]
[JsonSerializable(typeof(PlatformInfo))]
[JsonSerializable(typeof(AgentHelloMeta))]
[JsonSerializable(typeof(Ipv6Diagnostic))]
[JsonSerializable(typeof(AgentHeartbeatExtra))]
[JsonSerializable(typeof(RawChunkHeader))]
public sealed partial class MoShiroJsonContext : JsonSerializerContext;
@@ -10,8 +10,8 @@ public enum NodeRuntimeStatus
Initializing,
[JsonStringEnumMemberName("running")]
Running,
[JsonStringEnumMemberName("probe_paused")]
ProbePaused,
[JsonStringEnumMemberName("idle")]
Idle,
[JsonStringEnumMemberName("maintenance")]
Maintenance,
[JsonStringEnumMemberName("stopping")]
@@ -34,10 +34,10 @@ public sealed class TaskSpec
public sealed class PeerSpec
{
[JsonPropertyName("probe_id")]
public ProbeId ProbeId { get; set; }
[JsonPropertyName("node_id")]
public NodeId NodeId { get; set; }
/// <summary>Probe 的身份公钥(SPKI,base64)。</summary>
/// <summary>拨号方的身份公钥(SPKI,base64)。</summary>
[JsonPropertyName("pubkey")]
public string PublicKey { get; set; } = "";
}
@@ -53,7 +53,7 @@ public sealed class TaskSet
[JsonPropertyName("tasks")]
public List<TaskSpec> Tasks { get; set; } = [];
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
/// <summary>允许拨入这个节点的拨号方公钥。</summary>
[JsonPropertyName("peers")]
public List<PeerSpec> Peers { get; set; } = [];
@@ -8,7 +8,7 @@ public enum MeasurementDatagramType : byte
EchoRequest = 1,
EchoReply = 2,
/// <summary>单向数据(udp_train),以及 Probe 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。</summary>
/// <summary>单向数据(udp_train),以及 拨号方 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。</summary>
Data = 3,
}
@@ -6,7 +6,7 @@ using Fleet.Core;
namespace MoShiro.Protocol;
/// <summary>
/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 Probe 发起;TLS 是可选的(ClientHello 以 0x16 开头,
/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 拨号方 发起;TLS 是可选的(ClientHello 以 0x16 开头,
/// 明文首帧以 "MN" 开头,所以同一个端口可以区分)。首帧:
/// <c>"MN"(2) version(1) session_id(8) stream_id(4) nonce(16) mac(16)</c>,认证失败直接关闭连接;
/// 之后是 <c>VarInt 长度 | 正文</c> 的消息,回显类消息的正文是 <c>seq(u32) send_ts_us(i64) | padding</c>。
@@ -42,7 +42,7 @@ public sealed class Ipv6DiagnosticsTests
}
[Fact]
public void Probe_DoesNotRequireAnInboundListener_AndUnavailableDataStaysUnknown()
public void Dialer_DoesNotRequireAnInboundListener_AndUnavailableDataStaysUnknown()
{
Assert.Equal("ready", Ipv6Diagnostics.Evaluate(true, null, true, true).State);
Assert.Equal("unknown", Ipv6Diagnostics.Evaluate(true, null, null, null).State);
@@ -64,31 +64,31 @@ public sealed class MeasurementTests
public void RowStore_RecordsOnlyItsOwnSidesColumns_AndCutsExistingRowsInKeyOrder()
{
StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!;
var probe = new RowStore(msg, Side.Probe, 64);
Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Probe).Select(c => c.Name));
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Agent).Select(c => c.Name));
var dialer = new RowStore(msg, Side.Dialer, 64);
Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Listener).Select(c => c.Name));
for (int i = 0; i < 4; i++)
{
long key = probe.Append();
probe.Set(key, probe.ColumnIndex("size"), 64);
probe.Set(key, probe.ColumnIndex("t1"), 1000 + i);
long key = dialer.Append();
dialer.Set(key, dialer.ColumnIndex("size"), 64);
dialer.Set(key, dialer.ColumnIndex("t1"), 1000 + i);
}
probe.Set(2, probe.ColumnIndex("t4"), 5000);
RawStream cut = probe.Cut(1, 4, includeKey: true);
dialer.Set(2, dialer.ColumnIndex("t4"), 5000);
RawStream cut = dialer.Cut(1, 4, includeKey: true);
Assert.Equal(3, cut.Count);
Assert.Equal(["seq", "size", "t1", "t4"], cut.Columns.Select(c => c.Name));
Assert.Equal([1L, 2L, 3L], cut.Find("seq")!.Values);
Assert.Equal([false, true, false], cut.Find("t4")!.Valid!);
Assert.Throws<ArgumentException>(() => probe.ColumnIndex("t2"));
Assert.Throws<ArgumentException>(() => dialer.ColumnIndex("t2"));
}
[Fact]
public void RowStore_RingOverwritesOldRows_AndNeverReturnsAStaleOne()
{
var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 16);
var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 16);
store.SetRow(3, [30, 31]);
Assert.True(store.TryGet(3, 0, out long t2));
Assert.Equal(30, t2);
@@ -111,19 +111,19 @@ public sealed class MeasurementTests
private static RawChunk EchoFragment(params (uint Seq, long T1, long? T4)[] rows)
{
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)]));
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
foreach ((uint seq, long t1, long? t4) in rows)
{
msg.Add(seq, 64, t1, t4);
}
return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { ProbeOffsetUs = 1000 } }, [msg.Build()]);
return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
}
[Fact]
public void FragmentMerger_ProbeDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty()
public void FragmentMerger_DialerDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty()
{
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 64);
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64);
agent.SetRow(0, [2000, 2050]);
agent.SetRow(2, [4000, 4040]);
var source = new Source { Stores = { ["msg"] = agent } };
@@ -138,7 +138,7 @@ public sealed class MeasurementTests
}
[Fact]
public void FragmentMerger_ProbeDriven_WithoutAnyAgentRecords_KeepsTheProbeRows()
public void FragmentMerger_DialerDriven_WithoutAnyAgentRecords_KeepsTheDialerRows()
{
RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null));
RawStream msg = Assert.Single(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).Value);
@@ -148,7 +148,7 @@ public sealed class MeasurementTests
}
[Fact]
public void FragmentMerger_RefusesAFragmentWithoutTheProbeColumns()
public void FragmentMerger_RefusesAFragmentWithoutTheDialerColumns()
{
var bad = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq")]));
bad.Add(0);
@@ -159,13 +159,13 @@ public sealed class MeasurementTests
[Fact]
public void FragmentMerger_AgentDriven_TakesRowsFromTheAgent_AndMarksUnreceivedPacketsAsLost()
{
var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Agent, 64);
var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Listener, 64);
for (uint seq = 0; seq < 5; seq++)
{
agent.SetRow(seq, [1000, 100 + seq]);
}
var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsProbe("t_recv")]));
var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsDialer("t_recv")]));
pkt.Add(1, 5000).Add(1, 9999).Add(3, 6000); // sequence 1 arrives twice (the first one counts); 0, 2 and 4 are lost
var header = new RawChunkHeader { Kind = "udp_train" };
header.Params[FragmentKeys.SeqFrom] = Json("0");
@@ -187,7 +187,7 @@ public sealed class MeasurementTests
[Fact]
public void FragmentMerger_StreamsWithoutAgentColumns_PassThroughInDeclaredOrder()
{
var conn = new StreamBuilder(new StreamSpec("conn", [Col.U8("outcome"), Col.Seq("attempt"), Col.TsProbe("ts")]));
var conn = new StreamBuilder(new StreamSpec("conn", [Col.U8("outcome"), Col.Seq("attempt"), Col.TsDialer("ts")]));
conn.Add(0, 0, 100);
var fragment = new RawChunk(new RawChunkHeader { Kind = "connect" }, [conn.Build()]);
@@ -213,8 +213,8 @@ public sealed class MeasurementTests
public void FragmentMerger_UnionStreams_ConcatenateBothSidesRows_AndEachAgentRowIsDeliveredOnce()
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Agent).Count); // each side owns every column
var agent = new RowStore(info, Side.Agent, 64);
Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Listener).Count); // each side owns every column
var agent = new RowStore(info, Side.Listener, 64);
var recorder = new Source { Stores = { ["tcp_info"] = agent } };
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]);
agent.SetRow(agent.Append(), [600, 1, 40_000, 5_000, 10, 8, 120, 1_100, 1]);
@@ -230,19 +230,19 @@ public sealed class MeasurementTests
Assert.False(merged.Find("conn")!.IsValid(0));
Assert.Equal(1, merged.Find("conn")!.Values[1]);
// The agent's rows were consumed: the next chunk carries only the probe's own rows
// The agent's rows were consumed: the next chunk carries only the dialer's own rows
IReadOnlyList<RawStream> second = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value;
Assert.Equal(1, second.Single(x => x.Name == "tcp_info").Count);
}
[Fact]
public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheProbeHasNone()
public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheDialerHasNone()
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
var agent = new RowStore(info, Side.Agent, 64);
var agent = new RowStore(info, Side.Listener, 64);
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]);
// A probe on a platform without kernel sampling sends the echo rows only
// A dialer on a platform without kernel sampling sends the echo rows only
RawChunk fragment = EchoFragment((0, 1000, 3000));
IReadOnlyList<RawStream> merged = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source { Stores = { ["tcp_info"] = agent } }).Value;
RawStream rows = merged.Single(x => x.Name == "tcp_info");
@@ -353,12 +353,12 @@ public sealed class MeasurementTests
continue;
}
Assert.Contains(pair.Kind, BlockCatalog.ProbeKinds);
Assert.Contains(pair.Kind, BlockCatalog.DialerKinds);
BlockSpec spec = new(kind, ParamResolver.Resolve(kind.Params, null).Values!, 1, 7443, new FragmentScope(null, null, null));
IBlock listener = pair.Listener(env, spec);
// 声明了连接键、Agent 一侧有列的流:监听方必须为它保留按键记录,否则合并不出完整的行
foreach (StreamSpec stream in kind.Streams.Where(s => s.Key is not null && s.ColumnsOf(Side.Agent).Count > 0))
foreach (StreamSpec stream in kind.Streams.Where(s => s.Key is not null && s.ColumnsOf(Side.Listener).Count > 0))
{
Assert.NotNull(listener.Records(stream.Name));
}
@@ -389,18 +389,18 @@ public sealed class MeasurementTests
[Fact]
public void ChunkFinalizer_TakesStartAndDurationFromTheDataTimestamps_NotFromTheDeliveryTime()
{
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 64);
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64);
agent.SetRow(0, [2000, 2050]);
RawChunk fragment = EchoFragment((0, 1000, 3000));
IReadOnlyList<RawStream> streams = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source { Stores = { ["msg"] = agent } }).Value;
var anchor = new ClockAnchor(AgentMonoUs: 1_000_000, UtcMs: 1_700_000_000_000);
var anchor = new ClockAnchor(ListenerMonoUs: 1_000_000, UtcMs: 1_700_000_000_000);
RawChunk chunk = ChunkFinalizer.Finalize(Kinds.UdpEcho, fragment, streams, new NodeId("agt_x"), anchor, new Dictionary<string, JsonElement> { ["port"] = Json("7443") }, 7443, new ChunkId(Guid.NewGuid()));
// t1 = 1000 us (Probe clock) + offset 1000 = 2000 us on the Agent clock, which is 998 ms before the anchor
// t1 = 1000 us (拨号方 clock) + offset 1000 = 2000 us on the Agent clock, which is 998 ms before the anchor
Assert.Equal(1_700_000_000_000 - 998, chunk.Header.StartUtcMs);
Assert.Equal(7443, chunk.Header.Port);
Assert.False(chunk.Header.Partial);
Assert.Equal(1_000_000, chunk.Header.Clock.AgentMonoUs);
Assert.Equal(1_000_000, chunk.Header.Clock.ListenerMonoUs);
}
}
@@ -29,8 +29,11 @@ public sealed class MsQuicConnectionTests
}
}
[Fact]
public async Task DatagramConnection_CanSendCloseAndReconnect_WithoutBlockingNativeWorkers()
[Theory]
[InlineData("127.0.0.1", "127.0.0.1")]
[InlineData("::", "127.0.0.1")]
[InlineData("::", "::1")]
public async Task DatagramConnection_CanSendCloseAndReconnect_WithoutBlockingNativeWorkers(string bind, string peer)
{
var log = new CaptureLog();
var counters = new DataPlaneCounters();
@@ -39,7 +42,7 @@ public sealed class MsQuicConnectionTests
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256);
using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddYears(100));
await using var listener = new MsQuicListener(IPAddress.Loopback, [0], certificate,
await using var listener = new MsQuicListener(IPAddress.Parse(bind), [0], certificate,
(connection, data) =>
{
// Bad measurement payloads must be counted without flooding logs.
@@ -55,7 +58,7 @@ public sealed class MsQuicConnectionTests
{
var reply = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Loopback, listener.BoundPorts[0], 0,
MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Parse(peer), listener.BoundPorts[0], 0,
key.ExportSubjectPublicKeyInfo(), (_, data) => reply.TrySetResult(data.ToArray()), log, timeout.Token);
try
{
@@ -48,7 +48,7 @@ public sealed class RestartUpdaterTests
RestartUpdater updater = Create(builtAt: 5_000);
updater.Offer(Offer("dev-bbb", 2_000));
Assert.Equal("up_to_date", updater.Status().State);
updater.Offer(Offer("dev-ccc", 9_000, component: "probe"));
updater.Offer(Offer("dev-ccc", 9_000, component: "backend"));
Assert.Equal("up_to_date", updater.Status().State);
Assert.False(updater.RequestApply());
}
@@ -99,7 +99,7 @@ public sealed class CatalogTests
{
Version = 7,
Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }],
Peers = [new PeerSpec { ProbeId = new ProbeId("prb_1"), PublicKey = "AAAA" }],
Peers = [new PeerSpec { NodeId = new Fleet.Core.NodeId("agt_1"), PublicKey = "AAAA" }],
};
string text = JsonSerializer.Serialize(set, MoShiroJsonContext.Default.TaskSet);
TaskSet back = JsonSerializer.Deserialize(text, MoShiroJsonContext.Default.TaskSet)!;
@@ -108,14 +108,14 @@ public sealed class CatalogTests
Assert.Equal(7, back.Version);
Assert.Equal("tsk_1", back.Tasks[0].Id.Value);
Assert.Equal(10, back.Tasks[0].Params["rate_hz"].GetInt64());
Assert.Equal("prb_1", back.Peers[0].ProbeId.Value);
Assert.Equal("agt_1", back.Peers[0].NodeId.Value);
}
[Fact]
public void AgentEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
public void NodeEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
{
AgentEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.AgentEnrollMeta)!;
Assert.Equal(7443, meta.ProbeTcpPort);
NodeEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.NodeEnrollMeta)!;
Assert.Equal(0, meta.TcpPort); // 没有端口表示这个节点不监听
Assert.Equal(["udp_echo"], meta.Kinds);
}
@@ -7,11 +7,11 @@ public sealed class NodeRuntimeStatusTests
[Fact]
public void HeartbeatStatus_UsesStableSnakeCaseEnumValues()
{
var heartbeat = new AgentHeartbeatExtra { RuntimeStatus = NodeRuntimeStatus.ProbePaused };
string json = JsonSerializer.Serialize(heartbeat, MoShiroJsonContext.Default.AgentHeartbeatExtra);
var heartbeat = new NodeHeartbeatExtra { RuntimeStatus = NodeRuntimeStatus.Idle };
string json = JsonSerializer.Serialize(heartbeat, MoShiroJsonContext.Default.NodeHeartbeatExtra);
Assert.Contains("\"runtime_status\":\"probe_paused\"", json, StringComparison.Ordinal);
Assert.Equal(NodeRuntimeStatus.ProbePaused,
JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.AgentHeartbeatExtra)!.RuntimeStatus);
Assert.Contains("\"runtime_status\":\"idle\"", json, StringComparison.Ordinal);
Assert.Equal(NodeRuntimeStatus.Idle,
JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.NodeHeartbeatExtra)!.RuntimeStatus);
}
}
@@ -9,17 +9,17 @@ namespace MoShiro.Protocol.Tests;
public sealed class RawChunkCodecTests
{
[Fact]
public void Header_LegacyDefaultsToIpv4_AndIpv6RoundTripsToAnIndependentSeries()
public void Header_DefaultsToIpv4_AndIpv6RoundTripsToAnIndependentSeries()
{
RawChunkHeader legacy = System.Text.Json.JsonSerializer.Deserialize("{\"probe_id\":\"prb_legacy\"}", MoShiroJsonContext.Default.RawChunkHeader)!;
RawChunkHeader legacy = System.Text.Json.JsonSerializer.Deserialize("{\"dialer_id\":\"agt_legacy\"}", MoShiroJsonContext.Default.RawChunkHeader)!;
Assert.Equal(IpVersions.V4, legacy.IpVersion);
Assert.Equal("line:prb_legacy", legacy.Series);
Assert.Equal("line:agt_legacy", legacy.Series);
RawChunk chunk = SampleChunk();
chunk.Header.ProbeId = new ProbeId("prb_dual");
chunk.Header.DialerId = new Fleet.Core.NodeId("agt_dual");
chunk.Header.IpVersion = IpVersions.V6;
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(chunk)).Value;
Assert.Equal("ipv6", decoded.Header.IpVersion);
Assert.Equal("line:prb_dual:ipv6", decoded.Header.Series);
Assert.Equal("line:agt_dual:ipv6", decoded.Header.Series);
Assert.Equal(chunk.Find("msg")!.Count, decoded.Find("msg")!.Count);
}
@@ -42,7 +42,7 @@ public sealed class RawChunkCodecTests
Assert.Equal(original.Header.ChunkId, decoded.Header.ChunkId);
Assert.Equal("udp_echo", decoded.Header.Kind);
Assert.Equal(original.Header.Clock.ProbeOffsetUs, decoded.Header.Clock.ProbeOffsetUs);
Assert.Equal(original.Header.Clock.DialerOffsetUs, decoded.Header.Clock.DialerOffsetUs);
RawStream before = original.Find("msg")!;
RawStream after = decoded.Find("msg")!;
Assert.Equal(before.Count, after.Count);
@@ -93,7 +93,7 @@ public sealed class RawChunkCodecTests
{
// 布局变化(列编码、头部字段、字节序)会让这个测试失败:必须同时升级 container_version 并更新黄金字节。
RawChunk chunk = SampleChunk();
chunk.Header.Clock = new ChunkClock { AgentMonoUs = 1000, UtcMs = 1_700_000_000_000, ProbeOffsetUs = 500 };
chunk.Header.Clock = new ChunkClock { ListenerMonoUs = 1000, UtcMs = 1_700_000_000_000, DialerOffsetUs = 500 };
byte[] body = RawChunkCodec.EncodeBody(chunk);
uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(body);
string columns = Convert.ToHexStringLower(body.AsSpan(4 + (int)headerLength));
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff. Show more