From 4a9608ead452c754471da17708ebc2b7558b42cb Mon Sep 17 00:00:00 2001 From: justme Date: Thu, 22 Oct 2026 10:14:38 +0800 Subject: [PATCH] fleet: count only admitted peers against the session limit so silent connections cannot lock the roster out --- backend/fleet/src/Fleet.Link/Peer.cs | 35 +++++++++-- backend/fleet/tests/Fleet.Tests/LinkTests.cs | 62 +++++++++++++++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/backend/fleet/src/Fleet.Link/Peer.cs b/backend/fleet/src/Fleet.Link/Peer.cs index 8f49c55..490d6ca 100644 --- a/backend/fleet/src/Fleet.Link/Peer.cs +++ b/backend/fleet/src/Fleet.Link/Peer.cs @@ -27,7 +27,15 @@ public sealed class PeerListenerOptions public int MaxFrameBytes { get; init; } = 8 << 20; - public int MaxSessions { get; init; } = 8; + /// + /// 同时在线的会话数上限,只数已经通过敲门的对端。每个拨号方每个地址族一个会话,所以要容得下「名册里的节点数 × 2」。 + /// 之前这个上限连还没敲门的连接一起数:节点多起来之后,已建立的会话把名额占满,后到的拨号方(哪怕在名册里)一连上就被断开, + /// 而且一直连不上,重试只会得到同样的结果。 + /// + public int MaxSessions { get; init; } = 64; + + /// 还没通过敲门的连接数上限(陌生人、扫描器、还在握手的):敲门有读超时,占着名额的时间有限,但它们不能挤占已通过敲门的会话。 + public int MaxPending { get; init; } = 64; /// 敲门、TLS、挑战应答整个握手的上限。线路差的时候(RTT 好几秒、还丢包)握手本来就要很久,10 秒做不完就会一直连不上;敲门已经挡住了不认识的连接,放宽不会放大被占连接的风险。 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 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,7 +171,15 @@ public sealed class PeerListener finally { client.Dispose(); - Interlocked.Decrement(ref _sessions); + if (!admitted) + { + Interlocked.Decrement(ref _pending); + } + + if (counted) + { + Interlocked.Decrement(ref _sessions); + } } } diff --git a/backend/fleet/tests/Fleet.Tests/LinkTests.cs b/backend/fleet/tests/Fleet.Tests/LinkTests.cs index 395530e..e6ef970 100644 --- a/backend/fleet/tests/Fleet.Tests/LinkTests.cs +++ b/backend/fleet/tests/Fleet.Tests/LinkTests.cs @@ -303,7 +303,7 @@ public sealed class LinkTests private readonly CancellationTokenSource _stop = new(); private readonly NodeIdentity _identity; - public PeerFixture(Func lookup, Func? onSession = null) + public PeerFixture(Func lookup, Func? 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(); } + /// 名额只数通过敲门的会话:一堆还没敲门的连接占着,名册里的拨号方照样连得上(之前它们共用一个上限,节点多了之后后到的拨号方被永远拒绝)。 + [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(); + 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(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() {