using System.Net; using System.Net.Sockets; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Text.Json.Serialization; using Fleet.Core; using Fleet.Identity; using Fleet.Link; using Fleet.Rpc; namespace Fleet.Tests; public sealed class LinkTests { // ───────────────────────── 基础件 ───────────────────────── [Fact] public void ReplayWindow_AcceptsEachSequenceOnce_AndRejectsStaleOnes() { var window = new ReplayWindow(); Assert.True(window.TryAccept(100)); Assert.False(window.TryAccept(100), "duplicate"); Assert.True(window.TryAccept(99), "late but inside the window"); Assert.False(window.TryAccept(99)); Assert.True(window.TryAccept(105)); Assert.True(window.TryAccept(101)); Assert.True(window.TryAccept(100 + 1024), "the window slides"); Assert.False(window.TryAccept(100), "now older than the window"); Assert.True(window.TryAccept(100 + 5000), "a big jump clears everything"); Assert.False(window.TryAccept(100 + 5000)); Assert.True(window.TryAccept(100 + 4999)); } [Fact] public void Backoff_GrowsExponentially_IsCappedAndJittered_AndResets() { var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60)); TimeSpan previousCeiling = TimeSpan.Zero; for (int i = 0; i < 12; i++) { TimeSpan delay = backoff.Next(); double ceiling = Math.Min(60, Math.Pow(2, i)); Assert.InRange(delay.TotalSeconds, ceiling * 0.5 - 1e-9, ceiling + 1e-9); Assert.True(TimeSpan.FromSeconds(ceiling) >= previousCeiling); previousCeiling = TimeSpan.FromSeconds(ceiling); } backoff.Reset(); Assert.InRange(backoff.Next().TotalSeconds, 0.5, 1.0); } [Fact] public async Task Outbox_DeliversInOrder_UntilAcknowledged_AndResendsAfterAReconnect() { var outbox = new Outbox(1 << 20); Guid a = Guid.CreateVersion7(); Guid b = Guid.CreateVersion7(); outbox.Enqueue(16, a, [1]); outbox.Enqueue(16, b, [2]); outbox.Enqueue(16, a, [9]); // 同一个 msg_id 只保留一份 Assert.Equal(2, outbox.PendingCount); Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid first, out _)); Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid second, out _)); Assert.Equal((a, b), (first, second)); // 连接断了:还没确认的全部重发,顺序不变 outbox.ResetSent(); Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid again, out _)); Assert.Equal(a, again); outbox.Acknowledge([a, b, Guid.NewGuid()]); Assert.Equal(0, outbox.PendingCount); Assert.Equal(0, outbox.PendingBytes); } [Fact] public async Task Outbox_IsBounded_DropsTheOldest_AndReportsTheGap() { var outbox = new Outbox(maxBytes: 100); int gap = 0; outbox.OnGap = n => gap += n; for (int i = 0; i < 10; i++) { outbox.Enqueue(16, Guid.CreateVersion7(), new byte[40]); // 每条 57 字节 } Assert.Equal(1, outbox.PendingCount); Assert.Equal(9, outbox.Dropped); Assert.Equal(9, gap); Assert.True(outbox.PendingBytes <= 100); var counted = new Outbox(1 << 20, maxCount: 3); for (int i = 0; i < 5; i++) { counted.Enqueue(16, Guid.CreateVersion7(), [1]); } Assert.Equal(3, counted.PendingCount); Assert.Equal(2, counted.Dropped); _ = await counted.TakeNextAsync(1 << 20, default); } [Fact] public async Task Outbox_LimitsBytesInFlight_UntilTheServerAcknowledges() { var outbox = new Outbox(1 << 20); Guid first = Guid.CreateVersion7(); outbox.Enqueue(16, first, new byte[1000]); outbox.Enqueue(16, Guid.CreateVersion7(), new byte[1000]); await outbox.TakeNextAsync(1500, default); // 第一条放行 using var cts = new CancellationTokenSource(200); await Assert.ThrowsAnyAsync(async () => await outbox.TakeNextAsync(1500, cts.Token)); // 第二条超过在途上限,等 ACK outbox.Acknowledge([first]); Assert.NotNull(await outbox.TakeNextAsync(1500, default)); // 单条消息比在途上限还大时也要放行,否则永远卡住 var big = new Outbox(1 << 20); big.Enqueue(16, Guid.CreateVersion7(), new byte[5000]); Assert.NotNull(await big.TakeNextAsync(100, default)); } [Fact] public void SessionKeys_AgreeOnBothSides_AndBindTheHandshake() { using var client = new EphemeralKey(); using var server = new EphemeralKey(); byte[] nonce = RandomNumberGenerator.GetBytes(32); SessionCredentials c = client.Derive(server.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki); SessionCredentials s = server.Derive(client.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki); Assert.Equal(c.Key, s.Key); Assert.Equal(c.SessionId, s.SessionId); Assert.Equal(8, c.SessionId.Length); Assert.Equal(32, c.Key.Length); SessionCredentials otherNonce = client.Derive(server.PublicKeySpki, RandomNumberGenerator.GetBytes(32), client.PublicKeySpki, server.PublicKeySpki); Assert.NotEqual(c.Key, otherNonce.Key); using var third = new EphemeralKey(); Assert.NotEqual(c.Key, third.Derive(server.PublicKeySpki, nonce, third.PublicKeySpki, server.PublicKeySpki).Key); } [Fact] public void DataPlaneMac_DetectsAnyTampering() { byte[] key = RandomNumberGenerator.GetBytes(32); byte[] data = [1, 2, 3, 4, 5]; byte[] mac = new byte[DataPlaneMac.Bytes]; DataPlaneMac.Compute(key, data, mac); Assert.True(DataPlaneMac.Verify(key, data, mac)); data[2] ^= 1; Assert.False(DataPlaneMac.Verify(key, data, mac)); data[2] ^= 1; mac[0] ^= 1; Assert.False(DataPlaneMac.Verify(key, data, mac)); mac[0] ^= 1; Assert.False(DataPlaneMac.Verify(RandomNumberGenerator.GetBytes(32), data, mac)); } // ───────────────────────── 传输与会话 ───────────────────────── private static async Task<(TlsFrameTransport Left, TlsFrameTransport Right)> PairAsync(int maxFrame = 1 << 20) { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var client = new TcpClient(); Task accept = listener.AcceptTcpClientAsync(); await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port); TcpClient server = await accept; listener.Stop(); return (new TlsFrameTransport(client.GetStream(), maxFrame), new TlsFrameTransport(server.GetStream(), maxFrame)); } [Fact] public async Task TlsFrames_RoundTripTextAndBinary_AndCloseCleanly() { (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(); await using (left) await using (right) { await left.SendAsync(new RpcFrame(FrameKind.Text, "hello"u8.ToArray()), default); await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[] { 1, 2, 3 }), default); await left.SendAsync(new RpcFrame(FrameKind.Binary, ReadOnlyMemory.Empty), default); RpcFrame a = (await right.ReceiveAsync(default))!.Value; RpcFrame b = (await right.ReceiveAsync(default))!.Value; RpcFrame c = (await right.ReceiveAsync(default))!.Value; Assert.Equal((FrameKind.Text, "hello"), (a.Kind, System.Text.Encoding.UTF8.GetString(a.Data.Span))); Assert.Equal(new byte[] { 1, 2, 3 }, b.Data.ToArray()); Assert.Equal(0, c.Data.Length); await left.CloseAsync("done", default); Assert.Null(await right.ReceiveAsync(default)); } } [Fact] public async Task TlsFrames_RefuseOversizedFrames_OnBothSides() { (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(maxFrame: 100); await using (left) await using (right) { await Assert.ThrowsAsync(async () => await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[101]), default)); // 对端绕过长度检查发来超长帧:接收方直接断开 (TlsFrameTransport raw, TlsFrameTransport receiver) = await PairAsync(maxFrame: 100); await using (raw) await using (receiver) { var permissive = new TlsFrameTransport(await TakeStreamAsync(raw), 1 << 20); await permissive.SendAsync(new RpcFrame(FrameKind.Binary, new byte[500]), default); Assert.Null(await receiver.ReceiveAsync(default)); } } } private static Task TakeStreamAsync(TlsFrameTransport transport) => Task.FromResult((Stream)typeof(TlsFrameTransport).GetField("_stream", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(transport)!); [Fact] public async Task Session_CorrelatesRequestsAndResponses_BothWays_WithoutDeadlockingInHandlers() { (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(); RpcSession? rightSession = null; MethodTable table = TestTables.Of(TestTables.Req("left.double", RpcSide.Listener), TestTables.Req("right.relay"), TestTables.Req("right.unhandled")); var leftRouter = new RpcRouter(RpcSide.Dialer, table).On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result.Ok(new Number { Value = p.Value * 2 }))); var rightRouter = new RpcRouter(RpcSide.Listener, table).On( "right.relay", LinkJson.Default.Number, LinkJson.Default.Number, async (_, p, ct) => { // 处理函数里等待向请求方发起的请求:接收循环和处理函数在不同的任务里,所以不会死锁 Result inner = await rightSession!.RequestAsync("left.double", p, LinkJson.Default.Number, LinkJson.Default.Number, ct); return Result.Ok(new Number { Value = inner.Value.Value + 1 }); }); await using var leftSession = new RpcSession(left, leftRouter, "left", TimeProvider.System); rightSession = new RpcSession(right, rightRouter, "right", TimeProvider.System); await using (rightSession) { Task leftRun = leftSession.RunAsync(default); Task rightRun = rightSession.RunAsync(default); // left 请求 right.relay;right 的处理函数回头请求 left.double:21 → 42 → +1 Result result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default); Assert.Equal(43, result.Value.Value); Result unknown = await leftSession.RequestAsync("right.unhandled", null, default); Assert.Equal("fleet_unsupported", unknown.Error.Code.Value); await leftSession.CloseAsync("bye", default); await rightRun.WaitAsync(TimeSpan.FromSeconds(5)); await leftRun.WaitAsync(TimeSpan.FromSeconds(5)); } } [Fact] public async Task Session_RequestsFailWhenThePeerNeverAnswers_OrTheSessionCloses() { (TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(); await using var silent = right; // 对端不读也不回 await using var session = new RpcSession(left, new RpcRouter(RpcSide.Dialer, TestTables.Of(TestTables.Req("test.any"))), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) }); Task run = session.RunAsync(default); Result timedOut = await session.RequestAsync("test.any", null, default); Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code); Task> pending = session.RequestAsync("test.any", null, default, TimeSpan.FromSeconds(30)).AsTask(); await silent.CloseAsync("gone", default); Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code); await run.WaitAsync(TimeSpan.FromSeconds(5)); } // ───────────────────────── 点对点握手 ───────────────────────── private sealed class PeerFixture : IAsyncDisposable { private readonly CancellationTokenSource _stop = new(); private readonly NodeIdentity _identity; public PeerFixture(Func lookup, Func? onSession = null) { Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System)); _identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey()); Listener = new PeerListener( new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup, Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System), }, onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)), NullNodeLog.Instance, TimeProvider.System); Task = Listener.RunAsync(_stop.Token); } public X509Certificate2 Certificate { get; } public PeerListener Listener { get; } public Task Task { get; } public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate); /// Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。 public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo(); /// 名册:能敲开门的 Probe 公钥。 public List KnockPeers { get; } = []; public async ValueTask DisposeAsync() { await _stop.CancelAsync(); await Task; _identity.Dispose(); Certificate.Dispose(); _stop.Dispose(); } private static async Task KeepOpenAsync(IRpcTransport transport, CancellationToken ct) { while (await transport.ReceiveAsync(ct) is not null) { } } } [Fact] public async Task PeerHandshake_AuthenticatesTheProbe_AndBothSidesDeriveTheSameDataPlaneCredentials() { using NodeIdentity probe = NodeIdentity.Generate(); var seen = new TaskCompletionSource(); await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null, (info, transport, ct) => { seen.TrySetResult(info); return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None); }); agent.KnockPeers.Add(probe.PublicKeySpki); await agent.Listener.Started.Task; (PeerSessionInfo client, 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 (transport) { PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal("prb_1", server.PeerId); Assert.Equal(client.Credentials.Key, server.Credentials.Key); Assert.Equal(client.Credentials.SessionId, server.Credentials.SessionId); } } [Fact] public async Task PeerHandshake_RejectsUnknownProbes_AndForgedSignatures_WithTheSameError() { using NodeIdentity probe = 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 agent.Listener.Started.Task; InvalidOperationException unknown = await Assert.ThrowsAsync(async () => await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default)); InvalidOperationException forged = await Assert.ThrowsAsync(async () => await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default)); 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); 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); await agent.Listener.Started.Task; // 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到) using NodeIdentity another = NodeIdentity.Generate(); await Assert.ThrowsAnyAsync(async () => await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default)); } [Fact] public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake() { using NodeIdentity probe = NodeIdentity.Generate(); MethodTable table = TestTables.Of(TestTables.Req("agent.echo")); var router = new RpcRouter(RpcSide.Listener, table).On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result.Ok(new Number { Value = p.Value + 1 }))); await using var agent = new PeerFixture( _ => probe.PublicKeySpki, async (info, transport, ct) => { await using var session = new RpcSession(transport, router, info.PeerId, TimeProvider.System); await session.RunAsync(ct); }); agent.KnockPeers.Add(probe.PublicKeySpki); await agent.Listener.Started.Task; (_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default); await using var client = new RpcSession(transport, new RpcRouter(RpcSide.Dialer, table), "probe", TimeProvider.System); Task run = client.RunAsync(default); Result result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default); Assert.Equal(42, result.Value.Value); await client.CloseAsync("bye", default); await run.WaitAsync(TimeSpan.FromSeconds(5)); } [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); await agent.Listener.Started.Task; // 不说 TLS 的连接:握手失败,监听继续 using (var garbage = new TcpClient()) { await garbage.ConnectAsync(IPAddress.Loopback, agent.Listener.Port); 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); 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); await agent.Listener.Started.Task; // 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭) foreach (byte[] payload in new[] { new byte[] { 0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00 }.Concat(RandomNumberGenerator.GetBytes(64)).ToArray(), RandomNumberGenerator.GetBytes(32), [] }) { using var scanner = new TcpClient(); await scanner.ConnectAsync(IPAddress.Loopback, agent.Listener.Port); NetworkStream stream = scanner.GetStream(); if (payload.Length > 0) { await stream.WriteAsync(payload); } byte[] buffer = new byte[64]; int read; try { read = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10)); } catch (IOException) { read = 0; // 连接被重置也是「什么都没收到」 } 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); await ok.DisposeAsync(); } [Fact] public void SelfSignedCertificate_IsEcdsaP256_AndHasAStableFingerprint() { byte[] pfx = NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System); using X509Certificate2 certificate = NodeCertificate.LoadPfx(pfx); Assert.True(certificate.HasPrivateKey); Assert.Equal("1.2.840.10045.2.1", certificate.PublicKey.Oid.Value); // id-ecPublicKey Assert.Equal(NodeCertificate.SpkiFingerprint(certificate), NodeCertificate.SpkiFingerprint(NodeCertificate.LoadPfx(pfx))); Assert.Equal(32, NodeCertificate.SpkiFingerprint(certificate).Length); } } public sealed class Number { [JsonPropertyName("value")] public int Value { get; set; } } [JsonSerializable(typeof(Number))] internal sealed partial class LinkJson : JsonSerializerContext;