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()
{