432 lines
20 KiB
C#
432 lines
20 KiB
C#
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<OperationCanceledException>(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<TcpClient> 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<byte>.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<InvalidOperationException>(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<Stream> 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<string>? rightSession = null;
|
||
var leftRouter = new RpcRouter<string>().On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
|
||
var rightRouter = new RpcRouter<string>().On(
|
||
"right.relay", LinkJson.Default.Number, LinkJson.Default.Number,
|
||
async (_, p, ct) =>
|
||
{
|
||
// 处理函数里等待向请求方发起的请求:接收循环和处理函数在不同的任务里,所以不会死锁
|
||
Result<Number> inner = await rightSession!.RequestAsync("left.double", p, LinkJson.Default.Number, LinkJson.Default.Number, ct);
|
||
return Result<Number>.Ok(new Number { Value = inner.Value.Value + 1 });
|
||
});
|
||
|
||
await using var leftSession = new RpcSession<string>(left, leftRouter, "left", TimeProvider.System);
|
||
rightSession = new RpcSession<string>(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<Number> result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
|
||
Assert.Equal(43, result.Value.Value);
|
||
|
||
Result<JsonElement> unknown = await leftSession.RequestAsync("nope", 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<string>(left, new RpcRouter<string>(), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
|
||
Task run = session.RunAsync(default);
|
||
|
||
Result<JsonElement> timedOut = await session.RequestAsync("any", null, default);
|
||
Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code);
|
||
|
||
Task<Result<JsonElement>> pending = session.RequestAsync("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();
|
||
|
||
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
|
||
{
|
||
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
|
||
Listener = new PeerListener(
|
||
new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup },
|
||
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);
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
await _stop.CancelAsync();
|
||
await Task;
|
||
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<PeerSessionInfo>();
|
||
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);
|
||
});
|
||
await agent.Listener.Started.Task;
|
||
|
||
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, 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);
|
||
await agent.Listener.Started.Task;
|
||
|
||
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
|
||
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, 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.Spki, 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);
|
||
await agent.Listener.Started.Task;
|
||
|
||
byte[] wrong = SHA256.HashData("another certificate"u8);
|
||
await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
|
||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
|
||
{
|
||
using NodeIdentity probe = NodeIdentity.Generate();
|
||
var router = new RpcRouter<string>().On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
|
||
await using var agent = new PeerFixture(
|
||
_ => probe.PublicKeySpki,
|
||
async (info, transport, ct) =>
|
||
{
|
||
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
|
||
await session.RunAsync(ct);
|
||
});
|
||
await agent.Listener.Started.Task;
|
||
|
||
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(), "probe", TimeProvider.System);
|
||
Task run = client.RunAsync(default);
|
||
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
|
||
Assert.Equal(42, result.Value.Value);
|
||
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);
|
||
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.Spki, 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;
|