RateControl

This commit is contained in:
2026-07-14 02:27:33 +03:00
parent 4f6d2d0801
commit 1ca9fc477b
19 changed files with 879 additions and 146 deletions
+8
View File
@@ -78,6 +78,12 @@ internal static class Program
static async Task<MyService> StartServer(Warehouse warehouse, ushort port)
{
warehouse.RegisterAuthenticationProvider(new ServerAuthenticationProvider());
warehouse.Configuration.Parser.MaximumPacketSize = 8 * 1024 * 1024;
warehouse.Configuration.Parser.MaximumAllocationSize = 4 * 1024 * 1024;
warehouse.Configuration.Parser.MaximumCollectionItems = 65_536;
warehouse.Configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection = 4_096;
warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection = 128;
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 64;
warehouse.Configuration.RateControl.DenialsBeforeConnectionBlock = 10;
warehouse.AddRatePolicy(new BurstRatePolicy("standard-call")
{
@@ -139,6 +145,8 @@ internal static class Program
static async Task<EpConnection> ConnectClient(Warehouse warehouse, ushort port)
{
warehouse.RegisterAuthenticationProvider(new ClientAuthenticationProvider());
warehouse.Configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection = 4_096;
warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection = 128;
return await warehouse.Get<EpConnection>($"ep://localhost:{port}", new EpConnectionContext
{
+15
View File
@@ -13,6 +13,7 @@ Coverage includes:
- pull streams backed by `IAsyncEnumerable<T>`;
- `TerminateExecution` through async-enumerator disposal;
- `HaltExecution` and `ResumeExecution` for a cooperative push stream.
- parser allocation and collection budgets, attachment quotas, and per-IP connection limits.
Run it from the repository root:
@@ -41,3 +42,17 @@ public void Call()
{
}
```
Security limits are configured per Warehouse. A value of zero disables an individual limit:
```csharp
warehouse.Configuration.Parser.MaximumPacketSize = 8 * 1024 * 1024;
warehouse.Configuration.Parser.MaximumAllocationSize = 4 * 1024 * 1024;
warehouse.Configuration.Parser.MaximumCollectionItems = 65_536;
warehouse.Configuration.ResourceAttachments.MaximumAttachedResourcesPerConnection = 4_096;
warehouse.Configuration.ResourceAttachments.MaximumPendingAttachmentsPerConnection = 128;
warehouse.Configuration.ResourceAttachments.RejectDuplicateAttachments = true;
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 64;
```
@@ -0,0 +1,45 @@
using Esiur.Core;
using Esiur.Protocol;
namespace Esiur.Tests.Unit.Integration;
[Collection("Integration")]
public class AttachmentSecurityTests
{
[Fact]
public async Task ConcurrentFetches_AreCoalescedIntoOneAttachRequest()
{
await using var cluster = await StartCluster().WaitAsync(TimeSpan.FromSeconds(10));
var fetches = Enumerable.Range(0, 16)
.Select(_ => Task.Run(async () => await cluster.Connection.Get("sys/first")))
.ToArray();
var resources = await Task.WhenAll(fetches).WaitAsync(TimeSpan.FromSeconds(10));
Assert.All(resources, resource => Assert.Same(resources[0], resource));
Assert.Equal(1, cluster.Connection.ResourceAttachRequestCount);
}
[Fact]
public async Task Connection_RefusesResourcesBeyondConfiguredAttachmentLimit()
{
await using var cluster = await StartCluster().WaitAsync(TimeSpan.FromSeconds(10));
cluster.ClientWarehouse.Configuration.ResourceAttachments
.MaximumAttachedResourcesPerConnection = 1;
Assert.NotNull(await cluster.Connection.Get("sys/first"));
var exception = await Assert.ThrowsAsync<AsyncException>(async () =>
await cluster.Connection.Get("sys/second"));
Assert.Equal(ExceptionCode.AttachmentLimitExceeded, exception.Code);
Assert.Equal(1, cluster.Connection.ResourceAttachRequestCount);
}
static Task<IntegrationCluster> StartCluster()
=> IntegrationCluster.StartAsync(async warehouse =>
{
await warehouse.Put("sys/first", new RateLimitedResource());
await warehouse.Put("sys/second", new RateLimitedResource());
});
}
+55
View File
@@ -0,0 +1,55 @@
using Esiur.Data;
using Esiur.Net.Packets;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
public class ParserSecurityTests
{
[Fact]
public void PacketParser_RejectsOversizedDeclarationBeforePayloadArrives()
{
var warehouse = new Warehouse();
warehouse.Configuration.Parser.MaximumPacketSize = 1_024;
var packet = new EpPacket(warehouse);
// EP notification with a RawData TDU whose four-byte length declares 1 MiB,
// without supplying that payload. The declaration itself must be rejected.
var data = new byte[] { 0x20, 0x60, 0x00, 0x10, 0x00, 0x00 };
Assert.Throws<ParserLimitException>(() =>
{
packet.Parse(data, 0, (uint)data.Length);
});
}
[Fact]
public void RawDataParser_EnforcesAllocationBudget()
{
var warehouse = new Warehouse();
warehouse.Configuration.Parser.MaximumAllocationSize = 10;
var data = Codec.Compose(new byte[20], warehouse, null);
Assert.Throws<ParserLimitException>(() => Codec.ParseSync(data, 0, warehouse));
}
[Fact]
public void StringParser_AccountsForDecodedUtf16Allocation()
{
var warehouse = new Warehouse();
warehouse.Configuration.Parser.MaximumAllocationSize = 10;
var data = Codec.Compose("123456", warehouse, null);
Assert.Throws<ParserLimitException>(() => Codec.ParseSync(data, 0, warehouse));
}
[Fact]
public void ListParser_EnforcesCollectionItemBudget()
{
var warehouse = new Warehouse();
var data = Codec.Compose(new object[] { true, false, true }, warehouse, null);
warehouse.Configuration.Parser.MaximumCollectionItems = 2;
Assert.Throws<ParserLimitException>(() => Codec.ParseSync(data, 0, warehouse));
}
}
+94
View File
@@ -0,0 +1,94 @@
using System.Net;
using Esiur.Core;
using Esiur.Data;
using Esiur.Net;
using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
public class PeerConnectionLimitTests
{
[Fact]
public void Server_RejectsConnectionsAbovePerIpLimit_AndReleasesClosedSlot()
{
var warehouse = new Warehouse();
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 1;
var server = new EpServer();
server.Instance = new Instance(warehouse, 1, "server", server, null);
server.Connections = new AutoList<EpConnection, NetworkServer<EpConnection>>(server);
var address = IPAddress.Parse("192.0.2.10");
var firstSocket = new TestSocket(address, 10001);
var first = new EpConnection();
first.Assign(firstSocket);
server.Add(first);
var rejectedSocket = new TestSocket(address, 10002);
var rejected = new EpConnection();
rejected.Assign(rejectedSocket);
server.Add(rejected);
Assert.Single(server.Connections);
Assert.Equal(SocketState.Closed, rejectedSocket.State);
Assert.Equal(1, server.GetConnectionCount(address));
firstSocket.Close();
Assert.Equal(0, server.GetConnectionCount(address));
var replacementSocket = new TestSocket(address, 10003);
var replacement = new EpConnection();
replacement.Assign(replacementSocket);
server.Add(replacement);
Assert.Equal(SocketState.Established, replacementSocket.State);
Assert.Equal(1, server.GetConnectionCount(address));
replacementSocket.Close();
}
sealed class TestSocket : ISocket
{
public event DestroyedEvent? OnDestroy;
public SocketState State { get; private set; } = SocketState.Established;
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
public IPEndPoint RemoteEndPoint { get; }
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 10518);
public TestSocket(IPAddress address, int port)
=> RemoteEndPoint = new IPEndPoint(address, port);
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length)
=> new(true);
public void Send(byte[] message) { }
public void Send(byte[] message, int offset, int length) { }
public void Close()
{
if (State == SocketState.Closed)
return;
State = SocketState.Closed;
Receiver?.NetworkClose(this);
}
public AsyncReply<bool> Connect(string hostname, ushort port) => new(true);
public bool Begin() => true;
public AsyncReply<bool> BeginAsync() => new(true);
public AsyncReply<ISocket> AcceptAsync() => new((ISocket)null!);
public ISocket Accept() => null!;
public void Hold() { }
public void Unhold() { }
public void Destroy()
{
Close();
OnDestroy?.Invoke(this);
OnDestroy = null;
}
}
}