refactor!: rename the project from Monitor to MoShiro

Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
This commit is contained in:
justme committed 2026-10-07 01:49:04 +08:00
1 parent 17b35223aa
commit 83e5120377
309 files changed
+1505 -1499

No files matched your search

@@ -0,0 +1,433 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
using MoShiro.Protocol;
namespace MoShiro.TestSupport;
/// <summary>
/// 一个会说 Fleet 协议的假 Agent:用加入令牌注册 → 连接 → 签名认证 → hello → 接收期望状态 → 上传原始块并等待 ACK。
/// 只用于测试和离线工具,不是真正的节点。
/// </summary>
public sealed class AgentSimulator : IAsyncDisposable
{
private readonly NodeIdentity _identity;
private readonly ClientWebSocket _socket = new();
private readonly ConcurrentDictionary<Guid, TaskCompletionSource<bool>> _acks = new();
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _responses = new();
private readonly CancellationTokenSource _stop = new();
private readonly SemaphoreSlim _sendLock = new(1, 1);
private long _nextId;
private int _disposed;
private Task? _loop;
private AgentSimulator(NodeIdentity identity, NodeId nodeId, string status)
{
_identity = identity;
ExportedKey = identity.ExportPkcs8();
NodeId = nodeId;
EnrollmentStatus = status;
}
public NodeId NodeId { get; }
/// <summary>身份私钥(PKCS#8):释放之后仍可用它以同一个身份重新连接。</summary>
public byte[] ExportedKey { get; }
public string EnrollmentStatus { get; }
public string AuthStatus { get; private set; } = "";
public ConcurrentQueue<TaskSet> TaskSets { get; } = new();
public ConcurrentQueue<TestRunParams> TestRuns { get; } = new();
/// <summary>面板发来的发布清单(<c>moshiro.update.offer</c>)和更新请求(<c>moshiro.update.apply</c>)。</summary>
public ConcurrentQueue<UpdateOfferParams> UpdateOffers { get; } = new();
private int _updateApplies;
public int UpdateApplies => Volatile.Read(ref _updateApplies);
public ConcurrentQueue<TestId> TestCancels { get; } = new();
public IReadOnlyList<RejectedItem> RejectNextState { get; set; } = [];
public bool Revoked { get; private set; }
public string? CloseReason { get; private set; }
/// <summary>收到 moshiro.test.run 时的应答:默认接受。</summary>
public Func<TestRunParams, (bool Accepted, string? ErrorCode)> TestRunHandler { get; set; } = _ => (true, null);
public byte[] PublicKey => _identity.PublicKeySpki;
/// <summary>用这个节点的私钥为认证随机数签名(可以冒充别的节点 ID:用来测试服务端的校验)。</summary>
public byte[] SignAuth(NodeId claimedNode, string nonce) => _identity.Sign(SignaturePurpose.Auth, claimedNode.Value, nonce);
public static async Task<AgentSimulator> EnrollAsync(HttpClient http, string joinToken, string name, string[]? kinds = null, string publicHost = "203.0.113.10")
{
NodeIdentity identity = NodeIdentity.Generate();
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
byte[] tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(joinToken));
string proof = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), pubkey));
var body = new JsonObject
{
["join_token"] = joinToken,
["name"] = name,
["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
["proof"] = proof,
["node_version"] = "0.0.1-sim",
["meta"] = new JsonObject
{
["probe_tcp_port"] = 7443,
["probe_udp_ports"] = new JsonArray(7443),
["public_host"] = publicHost,
["kinds"] = new JsonArray((kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
},
};
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
using HttpResponseMessage response = await http.PostAsync("/api/agent/v1/enroll", content);
string text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
identity.Dispose();
throw new InvalidOperationException($"enroll failed: {(int)response.StatusCode} {text}");
}
JsonObject result = JsonNode.Parse(text)!.AsObject();
return new AgentSimulator(identity, new NodeId(result["node_id"]!.GetValue<string>()), result["status"]!.GetValue<string>());
}
/// <summary>用已有的身份恢复一个模拟器(例如后端重启之后同一个节点重新连接)。</summary>
public static AgentSimulator Restore(byte[] pkcs8, NodeId nodeId) => new(NodeIdentity.FromPkcs8(pkcs8), nodeId, "active");
/// <summary>连接并完成认证与 hello。认证失败时抛出异常。</summary>
public async Task ConnectAsync(Uri baseAddress, bool sendHello = true, string[]? kinds = null)
{
var uri = new UriBuilder(baseAddress) { Scheme = baseAddress.Scheme == "https" ? "wss" : "ws", Path = "/api/agent/v1/rpc" }.Uri;
await _socket.ConnectAsync(uri, _stop.Token);
RpcMessage challenge = await ReceiveMessageAsync(_stop.Token) ?? throw new InvalidOperationException("no challenge");
ChallengeParams nonce = challenge.Params!.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
string signature = Convert.ToBase64String(_identity.Sign(SignaturePurpose.Auth, NodeId.Value, nonce.Nonce));
await SendAsync(RpcMessage.Request(NextId(), FleetMethods.Auth, new AuthParams { NodeId = NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams));
RpcMessage? authReply = await ReceiveMessageAsync(_stop.Token);
if (authReply?.Error is { } error)
{
throw new InvalidOperationException($"auth failed: {error.Data?.Code} {error.Message}");
}
AuthStatus = authReply!.Result!.Value.Deserialize(FleetRpcContext.Default.AuthResult)!.Status;
_loop = Task.Run(ReceiveLoopAsync);
if (sendHello)
{
await HelloAsync(kinds);
}
}
public async Task HelloAsync(string[]? kinds = null)
{
var meta = JsonSerializer.SerializeToElement(
new AgentHelloMeta
{
TlsSpki = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
ProbeTcpPort = 7443,
ProbeUdpPorts = [7443],
LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 },
},
MoShiroJsonContext.Default.AgentHelloMeta);
var hello = new HelloParams
{
NodeVersion = "0.0.1-sim",
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"])],
NowUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(),
Meta = meta,
};
await RequestAsync(FleetMethods.Hello, JsonSerializer.SerializeToElement(hello, FleetRpcContext.Default.HelloParams));
}
/// <summary>上传一个原始块并等待服务端 ACK(持久化之后才会确认)。</summary>
/// <summary>设置之后,这个假 Agent 发出的每个块都归属于这个 Probe(夹具的默认 Probe)。</summary>
public ProbeId? ProbeForChunks { get; set; }
public async Task<bool> SendChunkAsync(RawChunk chunk, TimeSpan? timeout = null)
{
if (ProbeForChunks is { } probe)
{
chunk.Header.ProbeId = probe;
}
byte[] bytes = RawChunkCodec.Encode(chunk);
Guid id = chunk.Header.ChunkId.Value;
var ack = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_acks[id] = ack;
await SendBinaryAsync(OutboxFrame.Build(MoShiroChannels.RawChunk, id, bytes));
return await ack.Task.WaitAsync(timeout ?? TimeSpan.FromSeconds(10));
}
/// <summary>发送一个任意的文本帧(用来测试服务端对非法输入的处理)。</summary>
public Task SendTextAsync(string text) => SendFrameAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text);
/// <summary>发送一个任意的二进制帧(用来测试服务端对非法输入的处理)。</summary>
public Task SendBinaryAsync(byte[] frame) => SendFrameAsync(frame, WebSocketMessageType.Binary);
public Task SendEventAsync(string kind, string? detail = null) =>
SendAsync(RpcMessage.Notification(
MoShiroMethods.Event,
new AgentEventParams { Kind = kind, AtUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(), Detail = detail },
MoShiroJsonContext.Default.AgentEventParams));
public Task SendHostAsync(HostSnapshot snapshot) =>
SendAsync(RpcMessage.Notification(MoShiroMethods.Host, snapshot, MoShiroJsonContext.Default.HostSnapshot));
public Task SendTestStatusAsync(TestId test, string status, int? phase = null, string? errorCode = null) =>
SendAsync(RpcMessage.Notification(
MoShiroMethods.TestStatus,
new TestStatusParams { TestId = test, Status = status, Phase = phase, ErrorCode = errorCode },
MoShiroJsonContext.Default.TestStatusParams));
/// <summary>像节点的更新器报告的那样,随心跳上报更新状态。</summary>
public Task SendUpdateStatusAsync(UpdateStatus status) =>
SendAsync(RpcMessage.Notification(
FleetMethods.Heartbeat,
new HeartbeatParams { Extra = JsonSerializer.SerializeToElement(new AgentHeartbeatExtra { Update = status }, MoShiroJsonContext.Default.AgentHeartbeatExtra) },
FleetRpcContext.Default.HeartbeatParams));
public async Task<RpcMessage> RequestRawAsync(string method, JsonElement? parameters = null) => await RequestAsync(method, parameters);
public async Task<TaskSet> WaitForTaskSetAsync(Func<TaskSet, bool> predicate, TimeSpan? timeout = null)
{
long started = TimeProvider.System.GetTimestamp();
TimeSpan limit = timeout ?? TimeSpan.FromSeconds(10);
while (TimeProvider.System.GetElapsedTime(started) < limit)
{
foreach (TaskSet set in TaskSets.ToArray().Reverse())
{
if (predicate(set))
{
return set;
}
}
await Task.Delay(25);
}
throw new TimeoutException("the expected task set never arrived");
}
public async Task WaitUntilAsync(Func<bool> condition, TimeSpan? timeout = null)
{
long started = TimeProvider.System.GetTimestamp();
TimeSpan limit = timeout ?? TimeSpan.FromSeconds(10);
while (TimeProvider.System.GetElapsedTime(started) < limit)
{
if (condition())
{
return;
}
await Task.Delay(25);
}
throw new TimeoutException("condition was not met in time");
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
await _stop.CancelAsync();
if (_socket.State == WebSocketState.Open)
{
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
}
catch (WebSocketException)
{
// 已经断开
}
}
if (_loop is not null)
{
try
{
await _loop;
}
catch (OperationCanceledException)
{
// 正常结束
}
}
_socket.Dispose();
_identity.Dispose();
_stop.Dispose();
_sendLock.Dispose();
}
private long NextId() => Interlocked.Increment(ref _nextId);
private async Task<RpcMessage> RequestAsync(string method, JsonElement? parameters)
{
long id = NextId();
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_responses[id] = waiter;
await SendAsync(new RpcMessage { Id = id, Method = method, Params = parameters });
return await waiter.Task.WaitAsync(TimeSpan.FromSeconds(10));
}
private Task SendAsync(RpcMessage message) =>
SendFrameAsync(JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage), WebSocketMessageType.Text);
private async Task SendFrameAsync(byte[] bytes, WebSocketMessageType type)
{
await _sendLock.WaitAsync(_stop.Token);
try
{
await _socket.SendAsync(bytes, type, endOfMessage: true, _stop.Token);
}
finally
{
_sendLock.Release();
}
}
private async Task<RpcMessage?> ReceiveMessageAsync(CancellationToken ct)
{
var buffer = new byte[16 * 1024];
using var message = new MemoryStream();
while (true)
{
WebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
CloseReason = result.CloseStatusDescription;
if (_socket.State == WebSocketState.CloseReceived)
{
// 按协议确认对端发起的关闭
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "ack", CancellationToken.None);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
return null;
}
message.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
return JsonSerializer.Deserialize(message.ToArray(), FleetRpcContext.Default.RpcMessage);
}
}
}
private async Task ReceiveLoopAsync()
{
try
{
while (!_stop.IsCancellationRequested)
{
RpcMessage? message = await ReceiveMessageAsync(_stop.Token);
if (message is null)
{
return;
}
await HandleAsync(message);
}
}
catch (OperationCanceledException)
{
// 正常结束
}
catch (WebSocketException)
{
// 连接断开
}
}
private async Task HandleAsync(RpcMessage message)
{
if (message.IsResponse)
{
if (_responses.TryRemove(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
{
waiter.TrySetResult(message);
}
return;
}
switch (message.Method)
{
case FleetMethods.StateSync:
StateSyncParams sync = message.Params!.Value.Deserialize(FleetRpcContext.Default.StateSyncParams)!;
if (sync.Type == TaskSet.StateType)
{
TaskSets.Enqueue(sync.Doc.Deserialize(MoShiroJsonContext.Default.TaskSet)!);
}
var result = new StateSyncResult { AppliedVersion = sync.Version, Rejected = [.. RejectNextState] };
await SendAsync(RpcMessage.Success(message.Id!.Value, result, FleetRpcContext.Default.StateSyncResult));
break;
case FleetMethods.OutboxAck:
OutboxAckParams ack = message.Params!.Value.Deserialize(FleetRpcContext.Default.OutboxAckParams)!;
foreach (string id in ack.Ids.Concat(ack.Duplicates))
{
if (_acks.TryRemove(Guid.Parse(id), out TaskCompletionSource<bool>? waiter))
{
waiter.TrySetResult(ack.Duplicates.Contains(id) ? false : true);
}
}
break;
case FleetMethods.Revoked:
Revoked = true;
break;
case MoShiroMethods.UpdateOffer:
UpdateOffers.Enqueue(message.Params!.Value.Deserialize(MoShiroJsonContext.Default.UpdateOfferParams)!);
break;
case MoShiroMethods.UpdateApply:
Interlocked.Increment(ref _updateApplies);
break;
case MoShiroMethods.TestRun:
TestRunParams run = message.Params!.Value.Deserialize(MoShiroJsonContext.Default.TestRunParams)!;
TestRuns.Enqueue(run);
(bool accepted, string? errorCode) = TestRunHandler(run);
if (accepted)
{
await SendAsync(RpcMessage.Success(message.Id!.Value, new TestRunResult { Accepted = true }, MoShiroJsonContext.Default.TestRunResult));
}
else
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(new ErrorCode(errorCode ?? "test_busy"), "refused")));
}
break;
case MoShiroMethods.TestCancel:
TestCancelParams cancel = message.Params!.Value.Deserialize(MoShiroJsonContext.Default.TestCancelParams)!;
TestCancels.Enqueue(cancel.TestId);
await SendAsync(RpcMessage.Success(message.Id!.Value, new TestRunResult { Accepted = true }, MoShiroJsonContext.Default.TestRunResult));
break;
}
}
}
@@ -0,0 +1,64 @@
using System.Net.WebSockets;
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
namespace MoShiro.TestSupport;
public sealed record AuthAttempt(bool Ok, string? Status, string? ErrorCode, string? CloseReason);
/// <summary>只做 Fleet 的挑战/认证握手:用来测试被拒绝的连接(冒名、被禁用的节点等),不保持连接。</summary>
public static class AgentWire
{
public static async Task<AuthAttempt> AuthenticateAsync(Uri baseAddress, NodeId claimedNode, Func<string, byte[]> signNonce)
{
using var socket = new ClientWebSocket();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var uri = new UriBuilder(baseAddress) { Scheme = baseAddress.Scheme == "https" ? "wss" : "ws", Path = "/api/agent/v1/rpc" }.Uri;
await socket.ConnectAsync(uri, timeout.Token);
RpcMessage? challenge = await ReceiveAsync(socket, timeout.Token);
ChallengeParams nonce = challenge!.Params!.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
var auth = new AuthParams { NodeId = claimedNode.Value, Signature = Convert.ToBase64String(signNonce(nonce.Nonce)) };
byte[] request = JsonSerializer.SerializeToUtf8Bytes(RpcMessage.Request(1, FleetMethods.Auth, auth, FleetRpcContext.Default.AuthParams), FleetRpcContext.Default.RpcMessage);
await socket.SendAsync(request, WebSocketMessageType.Text, endOfMessage: true, timeout.Token);
string? closeReason = null;
while (true)
{
RpcMessage? reply = await ReceiveAsync(socket, timeout.Token);
if (reply is null)
{
closeReason = socket.CloseStatusDescription;
return new AuthAttempt(false, null, null, closeReason);
}
if (reply.IsResponse)
{
return reply.Error is { } error
? new AuthAttempt(false, null, error.Data?.Code, closeReason)
: new AuthAttempt(true, reply.Result!.Value.Deserialize(FleetRpcContext.Default.AuthResult)!.Status, null, null);
}
}
}
private static async Task<RpcMessage?> ReceiveAsync(ClientWebSocket socket, CancellationToken ct)
{
var buffer = new byte[16 * 1024];
using var message = new MemoryStream();
while (true)
{
WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
message.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
return JsonSerializer.Deserialize(message.ToArray(), FleetRpcContext.Default.RpcMessage);
}
}
}
}
@@ -0,0 +1,137 @@
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.TestSupport;
/// <summary>每一个回显包的设定:上行或下行丢失,以及两个方向的单向时延(微秒)。</summary>
public readonly record struct EchoPacket(bool LostUp, bool LostDown, long OwdUpUs, long OwdDownUs, long ProcessUs = 50);
/// <summary>合成原始块:用于测试、模拟器和离线工具。块头里的时钟锚点保证 Probe 与 Agent 时钟相差 <see cref="ProbeOffsetUs"/>。</summary>
public static class ChunkFactory
{
/// <summary>合成块默认属于哪个 Probe(测试夹具在后端创建一个真实的 Probe 之后把它的 id 写在这里)。</summary>
public static string DefaultProbe { get; set; } = "prb_test";
public const long AgentMonoAnchorUs = 1_000_000_000;
public const long ProbeOffsetUs = 5_000_000;
public static RawChunkHeader Header(NodeId agent, string kind, long startUtcMs, int durationMs = 10_000, IReadOnlyDictionary<string, JsonElement>? parameters = null)
{
KindSpec spec = Kinds.Find(kind)!;
ParamResolution resolved = ParamResolver.Resolve(spec.Params, parameters);
return new RawChunkHeader
{
ChunkId = new ChunkId(Guid.CreateVersion7()),
Kind = kind,
AgentId = agent,
ProbeId = new ProbeId(DefaultProbe),
Params = new Dictionary<string, JsonElement>(resolved.Values!.Values),
Port = 7443,
StartUtcMs = startUtcMs,
DurationMs = durationMs,
Clock = new ChunkClock { AgentMonoUs = AgentMonoAnchorUs, UtcMs = startUtcMs, ProbeOffsetUs = ProbeOffsetUs },
};
}
/// <summary>Probe 时钟下,距离块起点 <paramref name="offsetMs"/> 毫秒的时刻(微秒)。</summary>
public static long ProbeUs(long offsetMs) => (offsetMs * 1000) + AgentMonoAnchorUs - ProbeOffsetUs;
public static long AgentUs(long offsetMs) => (offsetMs * 1000) + AgentMonoAnchorUs;
public static RawChunk Echo(
NodeId agent,
string kind,
long startUtcMs,
int count,
int intervalMs,
Func<int, EchoPacket> packet,
int durationMs = 10_000,
IReadOnlyDictionary<string, JsonElement>? parameters = null,
IEnumerable<RawStream>? extraStreams = null)
{
var msg = new StreamBuilder(Kinds.Find(kind)!.FindStream("msg")!);
for (int i = 0; i < count; i++)
{
EchoPacket p = packet(i);
long t1 = ProbeUs((long)i * intervalMs);
long t2 = t1 + ProbeOffsetUs + p.OwdUpUs;
long t3 = t2 + p.ProcessUs;
long t4 = t3 - ProbeOffsetUs + p.OwdDownUs;
msg.Add(i, 64, t1, p.LostUp ? null : t2, p.LostUp ? null : t3, p.LostUp || p.LostDown ? null : t4);
}
var streams = new List<RawStream> { msg.Build() };
if (extraStreams is not null)
{
streams.AddRange(extraStreams);
}
return new RawChunk(Header(agent, kind, startUtcMs, durationMs, parameters), streams);
}
/// <summary>10 Hz 的 udp_echo,没有丢包,RTT = 2 × owdMs。</summary>
public static RawChunk UdpEchoClean(NodeId agent, long startUtcMs, int count = 100, double owdMs = 20) =>
Echo(agent, "udp_echo", startUtcMs, count, 100, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)));
public static RawStream TcpInfo(IEnumerable<(long TsUs, byte Side, uint SrttUs, uint RetransTotal, uint SegsOut)> samples)
{
var builder = new StreamBuilder(Kinds.TcpEcho.FindStream("tcp_info")!);
foreach ((long ts, byte side, uint srtt, uint retrans, uint segs) in samples)
{
builder.Add(ts, side, srtt, srtt / 4, 10, retrans, segs, segs * 100L);
}
return builder.Build();
}
public static RawChunk Connect(NodeId agent, long startUtcMs, IEnumerable<(long TcpUs, long? TlsUs, ConnectOutcomeCode Outcome)> attempts)
{
var builder = new StreamBuilder(Kinds.Connect.FindStream("conn")!);
int i = 0;
foreach ((long tcp, long? tls, ConnectOutcomeCode outcome) in attempts)
{
builder.Add(i, ProbeUs(i * 5000L), outcome == ConnectOutcomeCode.Timeout ? null : tcp, tls, (long)outcome);
i++;
}
return new RawChunk(Header(agent, "connect", startUtcMs), [builder.Build()]);
}
/// <summary>udp_train:Agent 以固定间隔发包,Probe 记录收到的时刻;<paramref name="lossEvery"/> 为 N 时每 N 个包丢一个。</summary>
public static RawChunk Train(NodeId agent, long startUtcMs, double mbps, int phase, int seconds = 10, int lossEvery = 0, long owdUs = 20_000)
{
const int size = 1250;
int packets = (int)(mbps * 1_000_000 / 8 / size * seconds);
double intervalUs = seconds * 1_000_000.0 / packets;
var pkt = new StreamBuilder(Kinds.UdpTrain.FindStream("pkt")!);
for (int i = 0; i < packets; i++)
{
long sendAgent = AgentMonoAnchorUs + (long)(i * intervalUs);
bool lost = lossEvery > 0 && i % lossEvery == 0;
long recvProbe = sendAgent + owdUs - ProbeOffsetUs;
pkt.Add(i, size, sendAgent, lost ? null : recvProbe);
}
var parameters = new Dictionary<string, JsonElement>
{
["mbps"] = JsonSerializer.SerializeToElement(mbps, FactoryJson.Default.Double),
["duration_s"] = JsonSerializer.SerializeToElement((double)seconds, FactoryJson.Default.Double),
};
RawChunkHeader header = Header(agent, "udp_train", startUtcMs, seconds * 1000, parameters);
header.Phase = phase;
header.TestId = new TestId("tst_test");
return new RawChunk(header, [pkt.Build(), new StreamBuilder(Kinds.UdpTrain.FindStream("step")!).Build()]);
}
}
public enum ConnectOutcomeCode
{
Ok = 0,
Timeout = 1,
Reset = 2,
TlsAlert = 3,
}
[System.Text.Json.Serialization.JsonSerializable(typeof(double))]
internal sealed partial class FactoryJson : System.Text.Json.Serialization.JsonSerializerContext;
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\shared\MoShiro.Protocol\MoShiro.Protocol.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,456 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
using MoShiro.Protocol;
namespace MoShiro.TestSupport;
/// <summary>
/// 假 Probe:通过真实的点对点握手(TLS 固定证书 + 签名 + ECDH)连接真实的 Agent,接收 <c>line_task_set</c>,
/// 在自己的 UDP 套接字上发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。
/// 它实现 Probe 的协议职责,是将来真实 Probe 的参照。
/// </summary>
public sealed class ProbeSimulator : IAsyncDisposable
{
private readonly NodeIdentity _identity;
private readonly RpcRouter<ProbeSimulator> _router;
private readonly ConcurrentDictionary<Guid, TaskCompletionSource> _acks = new();
private readonly ConcurrentDictionary<uint, EchoCollector> _echoes = new();
private readonly ConcurrentDictionary<uint, TrainCollector> _trains = new();
private readonly ConcurrentQueue<LineTaskSet> _taskSets = new();
private readonly long _origin = TimeProvider.System.GetTimestamp();
private readonly CancellationTokenSource _stop = new();
private NodeId _agentId;
private RpcSession<ProbeSimulator>? _rpc;
private UdpClient? _udp;
private IPEndPoint? _agentUdp;
private PeerSessionInfo? _info;
private Task? _receiver;
private Task? _run;
public ProbeSimulator(string probeId, NodeIdentity identity)
{
ProbeId = probeId;
_identity = identity;
_router = BuildRouter();
}
public string ProbeId { get; }
/// <summary>同一个身份的另一个假 Probe(模拟 Probe 重启:新的进程、新的会话,序号从 0 重新开始)。</summary>
public ProbeSimulator Restarted() => new(ProbeId, _identity);
/// <summary>true 时拒绝按需测试的阶段(用来测试 Agent 对 Probe 拒绝的处理)。</summary>
public bool RefusePhases { get; set; }
/// <summary>收到 phase_end 之后不交出列块,只报告 0 个(用来测试 Agent 的等待逻辑)。</summary>
public bool DropFragments { get; set; }
public long ClockOffsetUs { get; private set; }
public IReadOnlyCollection<LineTaskSet> TaskSets => [.. _taskSets];
public ConcurrentQueue<TestPhaseStartParams> Phases { get; } = new();
public ConcurrentQueue<TestId> Aborts { get; } = new();
public bool IsConnected => _rpc is not null && !_stop.IsCancellationRequested;
public long NowUs() => (TimeProvider.System.GetTimestamp() - _origin) * 1_000_000 / TimeProvider.System.TimestampFrequency;
public async Task ConnectAsync(string host, int controlPort, byte[] agentSpkiSha256, int udpPort, NodeId agentId, TimeProvider? time = null, CancellationToken ct = default)
{
_agentId = agentId;
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentSpkiSha256, _identity, ProbeId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, ct);
_info = info;
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
_agentUdp = new IPEndPoint(IPAddress.Parse(host), udpPort);
_rpc = new RpcSession<ProbeSimulator>(transport, _router, this, time ?? TimeProvider.System);
_rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
_run = _rpc.RunAsync(_stop.Token);
_receiver = Task.Run(ReceiveLoopAsync, _stop.Token);
await SyncClockAsync();
}
public async Task<LineTaskSet> WaitForTasksAsync(Func<LineTaskSet, bool> predicate, TimeSpan? timeout = null)
{
long started = Stopwatch.GetTimestamp();
TimeSpan limit = timeout ?? TimeSpan.FromSeconds(15);
while (Stopwatch.GetElapsedTime(started) < limit)
{
LineTaskSet? found = _taskSets.ToArray().LastOrDefault(predicate);
if (found is not null)
{
return found;
}
await Task.Delay(20);
}
throw new TimeoutException("the agent did not send the expected line task set");
}
/// <summary>NTP 式的时钟同步:偏移 = Agent 时钟 − (往返中点的) Probe 时钟。取最小往返的一次。</summary>
public async Task SyncClockAsync(int rounds = 5)
{
long bestRoundTrip = long.MaxValue;
for (int i = 0; i < rounds; i++)
{
long t0 = NowUs();
Result<ClockResult> reply = await _rpc!.RequestAsync(MoShiroMethods.Clock, new ClockParams { ProbeMonoUs = t0 }, MoShiroJsonContext.Default.ClockParams, MoShiroJsonContext.Default.ClockResult, CancellationToken.None);
long t1 = NowUs();
long roundTrip = t1 - t0;
if (reply.IsSuccess && roundTrip < bestRoundTrip)
{
bestRoundTrip = roundTrip;
ClockOffsetUs = reply.Value.AgentMonoUs - ((t0 + t1) / 2);
}
}
}
/// <summary>对一个 udp_echo 线路任务发 <paramref name="count"/> 个回显包,等回复,然后交出一个列块。返回交给 Agent 的列块的序号范围。</summary>
public async Task<(int Sent, int Received)> RunUdpEchoAsync(LineTask task, int count, TimeSpan interval, CancellationToken ct = default)
{
int size = (int)task.Params["size"].GetInt64();
var collector = new EchoCollector();
_echoes[task.StreamId] = collector;
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
byte[] packet = new byte[size];
for (uint seq = 0; seq < count; seq++)
{
long t1 = NowUs();
collector.Sent[seq] = t1;
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, t1), _info.Credentials.Key);
await _udp!.SendAsync(packet, packet.Length, _agentUdp!);
await Task.Delay(interval, ct);
}
await Task.Delay(300, ct); // 等最后的回复
_echoes.TryRemove(task.StreamId, out _);
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)]));
foreach ((uint seq, long t1) in collector.Sent.OrderBy(p => p.Key))
{
msg.Add(seq, size, t1, collector.Received.TryGetValue(seq, out long t4) ? t4 : null);
}
RawChunkHeader header = Header("udp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
await ShipAsync(new RawChunk(header, [msg.Build()]), ct: ct);
return (count, collector.Received.Count);
}
/// <summary>对一个 tcp_echo 线路任务建立一条 TCP(可选 TLS)连接,发 <paramref name="count"/> 条回显消息,然后交出一个列块。</summary>
public async Task<(int Sent, int Received)> RunTcpEchoAsync(LineTask task, int tcpPort, int count, TimeSpan interval, bool tls, CancellationToken ct = default)
{
using var client = new System.Net.Sockets.TcpClient();
await client.ConnectAsync(IPAddress.Loopback, tcpPort, ct);
Stream stream = client.GetStream();
if (tls)
{
#pragma warning disable CA5359 // 测试:Agent 的证书是自签名的,这里只验证数据面
var ssl = new System.Net.Security.SslStream(stream, false, (_, _, _, _) => true);
#pragma warning restore CA5359
await ssl.AuthenticateAsClientAsync(new System.Net.Security.SslClientAuthenticationOptions { TargetHost = "agent" }, ct);
stream = ssl;
}
byte[] auth = new byte[TcpWire.AuthBytes];
TcpWire.WriteAuth(auth, _info!.Credentials.SessionIdValue, task.StreamId, RandomNumberGenerator.GetBytes(TcpWire.NonceBytes), _info.Credentials.Key);
await stream.WriteAsync(auth, ct);
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)]));
int received = 0;
for (uint seq = 0; seq < count; seq++)
{
byte[] body = new byte[48];
long t1 = NowUs();
TcpWire.WriteEchoBody(body, seq, t1);
await TcpWire.WriteMessageAsync(stream, body, ct);
await stream.FlushAsync(ct);
byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct);
long? t4 = reply is not null && TcpWire.TryReadEchoBody(reply, out uint echoed, out _) && echoed == seq ? NowUs() : null;
received += t4 is null ? 0 : 1;
msg.Add(seq, body.Length, t1, t4);
await Task.Delay(interval, ct);
}
RawChunkHeader header = Header("tcp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
await ShipAsync(new RawChunk(header, [msg.Build()]), ct: ct);
return (count, received);
}
/// <summary>发送任意字节到 Agent 的 UDP 端口(用来测试 Agent 对垃圾包的处理)。</summary>
public Task SendRawUdpAsync(byte[] datagram) => _udp!.SendAsync(datagram, datagram.Length, _agentUdp!);
/// <summary>用伪造的密钥发一个回显包。</summary>
public Task SendForgedEchoAsync(LineTask task, uint seq, byte[]? key = null)
{
byte[] packet = new byte[64];
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), key ?? new byte[32]);
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
}
/// <summary>重放:用同一个序号、同样的密钥再发一次。</summary>
public Task SendEchoAsync(LineTask task, uint seq)
{
byte[] packet = new byte[64];
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), _info.Credentials.Key);
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
}
public int EchoRepliesSeen(uint streamId) => _echoes.TryGetValue(streamId, out EchoCollector? c) ? c.Received.Count : 0;
/// <summary>交出一个已经构造好的列块并等待 Agent 确认。</summary>
public async Task ShipAsync(RawChunk fragment, bool waitForAck = true, CancellationToken ct = default)
{
Guid id = fragment.Header.ChunkId.Value;
var ack = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_acks[id] = ack;
await _rpc!.SendBinaryAsync(OutboxFrame.Build(MoShiroChannels.ColumnBlock, id, RawChunkCodec.Encode(fragment)), ct);
if (waitForAck)
{
await ack.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
}
}
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
_udp?.Dispose();
if (_rpc is not null)
{
await _rpc.DisposeAsync();
}
foreach (Task? task in new[] { _run, _receiver })
{
if (task is not null)
{
try
{
await task.WaitAsync(TimeSpan.FromSeconds(2));
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException or ObjectDisposedException or SocketException or IOException)
{
// 关闭中
}
}
}
_stop.Dispose();
}
private RawChunkHeader Header(string kind, TaskId? task, TestId? test, int? phase, long startUtcMs, int durationMs, IReadOnlyDictionary<string, JsonElement>? extra)
{
var header = new RawChunkHeader
{
ChunkId = new ChunkId(Guid.CreateVersion7()),
Kind = kind,
AgentId = _agentId,
TaskId = task,
TestId = test,
Phase = phase,
ProbeId = new ProbeId(ProbeId),
StartUtcMs = startUtcMs,
DurationMs = durationMs,
Clock = new ChunkClock { ProbeOffsetUs = ClockOffsetUs },
};
if (extra is not null)
{
foreach ((string key, JsonElement value) in extra)
{
header.Params[key] = value;
}
}
return header;
}
private async Task ReceiveLoopAsync()
{
while (!_stop.IsCancellationRequested)
{
UdpReceiveResult result;
try
{
result = await _udp!.ReceiveAsync(_stop.Token);
}
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException)
{
if (_stop.IsCancellationRequested || ex is ObjectDisposedException)
{
return;
}
continue;
}
long now = NowUs();
byte[] data = result.Buffer;
if (!UdpPacket.TryParseHeader(data, out UdpHeader header) || !UdpPacket.VerifyMac(data, _info!.Credentials.Key))
{
continue;
}
if (header.Type == UdpPacketType.EchoReply && _echoes.TryGetValue(header.StreamId, out EchoCollector? echo))
{
echo.Received.TryAdd(header.Seq, now);
}
else if (header.Type == UdpPacketType.Data && _trains.TryGetValue(header.StreamId, out TrainCollector? train))
{
train.Received.TryAdd(header.Seq, now);
train.Size = data.Length;
}
}
}
private RpcRouter<ProbeSimulator> BuildRouter() =>
new RpcRouter<ProbeSimulator>()
.On(
FleetMethods.StateSync,
FleetRpcContext.Default.StateSyncParams,
FleetRpcContext.Default.StateSyncResult,
(probe, sync, _) =>
{
if (sync.Type == LineTaskSet.StateType)
{
probe._taskSets.Enqueue(sync.Doc.Deserialize(MoShiroJsonContext.Default.LineTaskSet)!);
}
return ValueTask.FromResult(Result<StateSyncResult>.Ok(new StateSyncResult { AppliedVersion = sync.Version }));
})
.On(
MoShiroMethods.TestPhase,
MoShiroJsonContext.Default.TestPhaseStartParams,
MoShiroJsonContext.Default.TestPhaseStartResult,
async (probe, start, ct) => Result<TestPhaseStartResult>.Ok(new TestPhaseStartResult { Accepted = await probe.StartPhaseAsync(start, ct) }))
.On(
MoShiroMethods.TestPhaseEnd,
MoShiroJsonContext.Default.TestPhaseEndParams,
MoShiroJsonContext.Default.TestPhaseEndResult,
async (probe, end, ct) => Result<TestPhaseEndResult>.Ok(new TestPhaseEndResult { Fragments = await probe.EndPhaseAsync(end, ct) }))
.OnNotify(
MoShiroMethods.TestAbort,
MoShiroJsonContext.Default.TestAbortParams,
(probe, abort, _) =>
{
probe.Aborts.Enqueue(abort.TestId);
return ValueTask.CompletedTask;
})
.OnNotify(
FleetMethods.OutboxAck,
FleetRpcContext.Default.OutboxAckParams,
(probe, ack, _) =>
{
foreach (string id in ack.Ids.Concat(ack.Duplicates))
{
if (probe._acks.TryRemove(Guid.Parse(id), out TaskCompletionSource? waiter))
{
waiter.TrySetResult();
}
}
return ValueTask.CompletedTask;
});
private readonly ConcurrentDictionary<uint, TestPhaseStartParams> _activePhases = new();
private async Task<bool> StartPhaseAsync(TestPhaseStartParams start, CancellationToken ct)
{
Phases.Enqueue(start);
if (RefusePhases)
{
return false;
}
_activePhases[start.StreamId] = start;
if (start.Kind == "udp_train")
{
_trains[start.StreamId] = new TrainCollector { StartUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() };
// 先发几个探活包,让 Agent 学到(最新的)源地址,再回复 accepted
byte[] hello = new byte[64];
for (uint seq = 0; seq < 3; seq++)
{
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, _info!.Credentials.SessionIdValue, start.StreamId, seq, NowUs()), _info.Credentials.Key);
await _udp!.SendAsync(hello.AsMemory(), _agentUdp!, ct);
}
}
return true;
}
private async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
{
_activePhases.TryRemove(end.StreamId, out TestPhaseStartParams? start);
if (start is null || DropFragments)
{
_trains.TryRemove(end.StreamId, out _);
return 0;
}
if (start.Kind == "udp_train" && _trains.TryRemove(end.StreamId, out TrainCollector? train))
{
await Task.Delay(200, ct); // 最后一批包还在路上
// 单块的记录数有上限(ProtocolLimits.MaxRecordsPerChunk):按序号范围切成多个列块,Agent 一侧按同样的范围取行
const int PerFragment = 40_000;
uint total = (uint)end.SentCount;
int fragments = 0;
for (uint from = 0; from < total || fragments == 0; from += PerFragment)
{
uint to = (uint)Math.Min((long)from + PerFragment, total);
var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsProbe("t_recv", nullable: true)]));
foreach ((uint seq, long recv) in train.Received.Where(p => p.Key >= from && p.Key < to).OrderBy(p => p.Key))
{
pkt.Add(seq, recv);
}
var streams = new List<RawStream> { pkt.Build() };
if (fragments == 0)
{
var step = new StreamBuilder(Kinds.UdpTrain.FindStream("step")!);
step.Add(0, (long)start.Params["mbps"].GetDouble(), NowUs() - (start.DurationSeconds * 1_000_000L), NowUs());
streams.Add(step.Build());
}
RawChunkHeader header = Header("udp_train", null, start.TestId, start.Phase, train.StartUtcMs, start.DurationSeconds * 1000, start.Params);
header.Params[FragmentKeys.SeqFrom] = JsonSerializer.SerializeToElement(from);
header.Params[FragmentKeys.SeqTo] = JsonSerializer.SerializeToElement(to);
// 不能在这里等确认:确认是 Agent 的通知,由同一个入站队列顺序处理,而当前正处在 phase_end 的处理函数里
await ShipAsync(new RawChunk(header, streams), waitForAck: false, ct: ct);
fragments++;
if (total == 0)
{
break;
}
}
return fragments;
}
return 0;
}
private sealed class EchoCollector
{
public ConcurrentDictionary<uint, long> Sent { get; } = new();
public ConcurrentDictionary<uint, long> Received { get; } = new();
}
private sealed class TrainCollector
{
public ConcurrentDictionary<uint, long> Received { get; } = new();
public int Size { get; set; }
public long StartUtcMs { get; init; }
}
}