refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
justme committed 2026-10-07 02:03:25 +08:00
1 parent 27ac7fb787
commit 2e146d4da7
309 files changed
+1565 -1572

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; }
}
}