fleet: count only admitted peers against the session limit so silent connections cannot lock the roster out

This commit is contained in:
justme committed 2026-10-22 10:14:38 +08:00
1 parent fee236fe56
commit 4a9608ead4
2 files changed
+91 -4

No files matched your search

+30 -3
View File
@@ -27,7 +27,15 @@ public sealed class PeerListenerOptions
public int MaxFrameBytes { get; init; } = 8 << 20;
public int MaxSessions { get; init; } = 8;
/// <summary>
/// 同时在线的会话数上限,只数已经通过敲门的对端。每个拨号方每个地址族一个会话,所以要容得下「名册里的节点数 × 2」。
/// 之前这个上限连还没敲门的连接一起数:节点多起来之后,已建立的会话把名额占满,后到的拨号方(哪怕在名册里)一连上就被断开,
/// 而且一直连不上,重试只会得到同样的结果。
/// </summary>
public int MaxSessions { get; init; } = 64;
/// <summary>还没通过敲门的连接数上限(陌生人、扫描器、还在握手的):敲门有读超时,占着名额的时间有限,但它们不能挤占已通过敲门的会话。</summary>
public int MaxPending { get; init; } = 64;
/// <summary>敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。</summary>
public TimeSpan HandshakeTimeout { get; init; } = PeerTimings.Handshake;
@@ -44,6 +52,7 @@ public sealed class PeerListener
private readonly INodeLog _log;
private readonly TimeProvider _time;
private int _sessions;
private int _pending;
public PeerListener(PeerListenerOptions options, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task> onSession, INodeLog log, TimeProvider time)
{
@@ -74,9 +83,9 @@ public sealed class PeerListener
while (!ct.IsCancellationRequested)
{
TcpClient client = await listener.AcceptTcpClientAsync(ct);
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
if (Interlocked.Increment(ref _pending) > _options.MaxPending)
{
Interlocked.Decrement(ref _sessions);
Interlocked.Decrement(ref _pending);
client.Dispose();
continue;
}
@@ -97,6 +106,7 @@ public sealed class PeerListener
private async Task HandleAsync(TcpClient client, CancellationToken ct)
{
bool admitted = false;
bool counted = false;
string phase = "knock";
long started = _time.GetTimestamp();
EndPoint? remote = client.Client.RemoteEndPoint;
@@ -113,6 +123,15 @@ public sealed class PeerListener
}
admitted = true;
Interlocked.Decrement(ref _pending);
if (Interlocked.Increment(ref _sessions) > _options.MaxSessions)
{
Interlocked.Decrement(ref _sessions);
_log.Warn("peer session limit reached", ("remote", remote), ("limit", _options.MaxSessions));
return;
}
counted = true;
phase = "tls";
_log.Info("peer knock accepted", ("remote", remote), ("phase", phase));
var tls = new SslStream(stream, leaveInnerStreamOpen: false);
@@ -152,9 +171,17 @@ public sealed class PeerListener
finally
{
client.Dispose();
if (!admitted)
{
Interlocked.Decrement(ref _pending);
}
if (counted)
{
Interlocked.Decrement(ref _sessions);
}
}
}
private async Task<PeerSessionInfo?> HandshakeAsync(TlsFrameTransport transport, EndPoint? remote, CancellationToken ct)
{
+61 -1
View File
@@ -303,7 +303,7 @@ public sealed class LinkTests
private readonly CancellationTokenSource _stop = new();
private readonly NodeIdentity _identity;
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null, int maxSessions = 64)
{
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
_identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey());
@@ -314,6 +314,7 @@ public sealed class LinkTests
Certificate = Certificate,
LookupPeerKey = lookup,
Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System),
MaxSessions = maxSessions,
},
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
NullNodeLog.Instance, TimeProvider.System);
@@ -462,6 +463,65 @@ public sealed class LinkTests
await ok.DisposeAsync();
}
/// <summary>名额只数通过敲门的会话:一堆还没敲门的连接占着,名册里的拨号方照样连得上(之前它们共用一个上限,节点多了之后后到的拨号方被永远拒绝)。</summary>
[Fact]
public async Task PeerListener_SessionLimitCountsOnlyAdmittedPeers_SoSilentConnectionsCannotLockOutTheRoster()
{
using NodeIdentity first = NodeIdentity.Generate();
using NodeIdentity second = NodeIdentity.Generate();
using NodeIdentity third = NodeIdentity.Generate();
await using var agent = new PeerFixture(id => id == "dlr_1" ? first.PublicKeySpki : id == "dlr_2" ? second.PublicKeySpki : third.PublicKeySpki, maxSessions: 2);
agent.KnockPeers.AddRange([first.PublicKeySpki, second.PublicKeySpki, third.PublicKeySpki]);
await agent.Listener.Started.Task;
// 20 个什么都不说的连接:占着待敲门的名额,但不占会话名额
var silent = new List<TcpClient>();
try
{
for (int i = 0; i < 20; i++)
{
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
silent.Add(client);
}
(_, TlsFrameTransport a) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, first, "dlr_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport b) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, second, "dlr_2", null, 1 << 20, TimeProvider.System, default);
// 会话名额(2)满了:第三个在名册里的拨号方被拒绝,一个会话结束之后又能连上
await Assert.ThrowsAnyAsync<Exception>(async () =>
{
(_, TlsFrameTransport refused) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, third, "dlr_3", null, 1 << 20, TimeProvider.System, default);
await refused.DisposeAsync();
});
await a.DisposeAsync();
bool reconnected = false;
for (int attempt = 0; attempt < 50 && !reconnected; attempt++)
{
try
{
(_, TlsFrameTransport again) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, third, "dlr_3", null, 1 << 20, TimeProvider.System, default);
await again.DisposeAsync();
reconnected = true;
}
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException or InvalidOperationException)
{
await Task.Delay(100);
}
}
Assert.True(reconnected);
await b.DisposeAsync();
}
finally
{
foreach (TcpClient client in silent)
{
client.Dispose();
}
}
}
[Fact]
public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
{