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

@@ -18,7 +18,7 @@ namespace MoShiro.TestSupport;
public sealed class AgentSimulator : IAsyncDisposable
{
private readonly NodeIdentity _identity;
private readonly NodeIdentity _tlsKey = NodeIdentity.Generate();
private readonly NodeIdentity _tlsKey;
private readonly ClientWebSocket _socket = new();
private readonly ConcurrentDictionary<Guid, TaskCompletionSource<bool>> _acks = new();
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _responses = new();
@@ -28,9 +28,11 @@ public sealed class AgentSimulator : IAsyncDisposable
private int _disposed;
private Task? _loop;
private AgentSimulator(NodeIdentity identity, NodeId nodeId, string status)
private AgentSimulator(NodeIdentity identity, NodeIdentity tlsKey, NodeId nodeId, string status, bool listens)
{
_identity = identity;
_tlsKey = tlsKey;
Listens = listens;
ExportedKey = identity.ExportPkcs8();
NodeId = nodeId;
EnrollmentStatus = status;
@@ -38,6 +40,9 @@ public sealed class AgentSimulator : IAsyncDisposable
public NodeId NodeId { get; }
/// <summary>是否像监听的节点那样上报端口(false:只拨出的节点)。</summary>
public bool Listens { get; set; }
/// <summary>身份私钥(PKCS#8):释放之后仍可用它以同一个身份重新连接。</summary>
public byte[] ExportedKey { get; }
@@ -72,19 +77,19 @@ public sealed class AgentSimulator : IAsyncDisposable
public byte[] PublicKey => _identity.PublicKeySpki;
/// <summary>hello 里上报的 TLS 证书公钥(Probe 用它固定证书、派生敲门密钥)。</summary>
/// <summary>注册时交给面板的 TLS 证书公钥(拨号方用它固定证书、派生敲门密钥)。</summary>
public byte[] TlsPublicKey => _tlsKey.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")
public static async Task<AgentSimulator> EnrollAsync(HttpClient http, string joinToken, string name, string[]? kinds = null, string publicHost = "203.0.113.10", bool listens = true)
{
NodeIdentity identity = NodeIdentity.Generate();
NodeIdentity tls = NodeIdentity.Generate();
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
byte[] tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(joinToken));
string proof = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, Convert.ToHexStringLower(tokenHash), pubkey));
using NodeIdentity tls = NodeIdentity.Generate();
var body = new JsonObject
{
["join_token"] = joinToken,
@@ -96,10 +101,9 @@ public sealed class AgentSimulator : IAsyncDisposable
["node_version"] = "0.0.1-sim",
["meta"] = new JsonObject
{
["probe_tcp_port"] = 7443,
["probe_quic_ports"] = new JsonArray(7443),
["probe_udp_ports"] = new JsonArray(7443),
["public_host"] = publicHost,
["tcp_port"] = listens ? 7443 : 0,
["quic_ports"] = listens ? new JsonArray(7443) : new JsonArray(),
["public_host"] = listens ? publicHost : "",
["kinds"] = new JsonArray((kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
},
};
@@ -110,15 +114,16 @@ public sealed class AgentSimulator : IAsyncDisposable
if (!response.IsSuccessStatusCode)
{
identity.Dispose();
tls.Dispose();
throw new InvalidOperationException($"enroll failed: {(int)response.StatusCode} {text}");
}
JsonObject result = JsonNode.Parse(text)!.AsObject();
return new AgentSimulator(identity, new NodeId(result["node_id"]!.GetValue<string>()), result["status"]!.GetValue<string>());
return new AgentSimulator(identity, tls, new NodeId(result["node_id"]!.GetValue<string>()), result["status"]!.GetValue<string>(), listens);
}
/// <summary>用已有的身份恢复一个模拟器(例如后端重启之后同一个节点重新连接)。</summary>
public static AgentSimulator Restore(byte[] pkcs8, NodeId nodeId) => new(NodeIdentity.FromPkcs8(pkcs8), nodeId, "active");
public static AgentSimulator Restore(byte[] pkcs8, NodeId nodeId, bool listens = true) => new(NodeIdentity.FromPkcs8(pkcs8), NodeIdentity.Generate(), nodeId, "active", listens);
/// <summary>连接并完成认证与 hello。认证失败时抛出异常。</summary>
public async Task ConnectAsync(Uri baseAddress, bool sendHello = true, string[]? kinds = null)
@@ -150,16 +155,12 @@ public sealed class AgentSimulator : IAsyncDisposable
public async Task HelloAsync(string[]? kinds = null)
{
var meta = JsonSerializer.SerializeToElement(
new AgentHelloMeta
new NodeHelloMeta
{
TlsPublicKey = Convert.ToBase64String(_tlsKey.PublicKeySpki),
Addresses = Addresses is null ? null : [.. Addresses],
ProbeTcpPort = 7443,
ProbeQuicPorts = [7443],
LegacyProbeUdpPorts = [7443],
Listen = Listens ? new ListenInfo { TcpPort = 7443, QuicPorts = [7443], Addresses = Addresses is null ? null : [.. Addresses] } : null,
LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 },
},
MoShiroJsonContext.Default.AgentHelloMeta);
MoShiroJsonContext.Default.NodeHelloMeta);
var hello = new HelloParams
{
NodeVersion = "0.0.1-sim",
@@ -171,14 +172,14 @@ public sealed class AgentSimulator : IAsyncDisposable
}
/// <summary>上传一个原始块并等待服务端 ACK(持久化之后才会确认)。</summary>
/// <summary>设置之后,这个假 Agent 发出的每个块都归属于这个 Probe(夹具的默认 Probe)。</summary>
public ProbeId? ProbeForChunks { get; set; }
/// <summary>设置之后,这个假 Agent 发出的每个块都归属于这个拨号方(夹具的默认拨号方)。</summary>
public NodeId? DialerForChunks { get; set; }
public async Task<bool> SendChunkAsync(RawChunk chunk, TimeSpan? timeout = null)
{
if (ProbeForChunks is { } probe)
if (DialerForChunks is { } dialer)
{
chunk.Header.ProbeId = probe;
chunk.Header.DialerId = dialer;
}
byte[] bytes = RawChunkCodec.Encode(chunk);
@@ -214,7 +215,7 @@ public sealed class AgentSimulator : IAsyncDisposable
public Task SendUpdateStatusAsync(UpdateStatus status) =>
SendAsync(RpcMessage.Notification(
FleetMethods.Heartbeat,
new HeartbeatParams { Extra = JsonSerializer.SerializeToElement(new AgentHeartbeatExtra { Update = status }, MoShiroJsonContext.Default.AgentHeartbeatExtra) },
new HeartbeatParams { Extra = JsonSerializer.SerializeToElement(new NodeHeartbeatExtra { Update = status }, MoShiroJsonContext.Default.NodeHeartbeatExtra) },
FleetRpcContext.Default.HeartbeatParams));
public async Task<RpcMessage> RequestRawAsync(string method, JsonElement? parameters = null) => await RequestAsync(method, parameters);