Add configurable media-aware managed session reaping
This commit is contained in:
@@ -7,10 +7,11 @@ using VoiceCat.Protocol;
|
||||
|
||||
namespace VoiceCat.Server.Transport;
|
||||
|
||||
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto)
|
||||
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto, SessionActivity? activity = null)
|
||||
{
|
||||
public byte[] Token { get; } = token;
|
||||
public MediaSessionCrypto Crypto { get; } = crypto;
|
||||
public SessionActivity Activity { get; } = activity ?? new(TimeProvider.System);
|
||||
// Only the UDP loop reads or changes the endpoint and binding state.
|
||||
public SocketAddress? Endpoint { get; set; }
|
||||
public void Dispose() { Crypto.Dispose(); CryptographicOperations.ZeroMemory(Token); }
|
||||
@@ -101,10 +102,15 @@ internal sealed class MediaRelay : IAsyncDisposable
|
||||
if (source is null) continue;
|
||||
if (header.Type == MediaFrameType.Keepalive)
|
||||
{
|
||||
if (length == VoiceFrameHeader.Size) await SendAsync(input.AsMemory(0, length), sender).ConfigureAwait(false);
|
||||
if (length == VoiceFrameHeader.Size)
|
||||
{
|
||||
source.Peer.Activity.Touch();
|
||||
await SendAsync(input.AsMemory(0, length), sender).ConfigureAwait(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!fanout.TryStart(input.AsSpan(0, length), source, current)) continue;
|
||||
source.Peer.Activity.Touch();
|
||||
while (fanout.TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint))
|
||||
await SendAsync(packet, endpoint!).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace VoiceCat.Server.Transport;
|
||||
|
||||
internal sealed class SessionActivity(TimeProvider clock)
|
||||
{
|
||||
private long lastSeen = clock.GetTimestamp();
|
||||
public void Touch()
|
||||
{
|
||||
long now = clock.GetTimestamp();
|
||||
long previous = Volatile.Read(ref lastSeen);
|
||||
while (now > previous)
|
||||
{
|
||||
long observed = Interlocked.CompareExchange(ref lastSeen, now, previous);
|
||||
if (observed == previous) return;
|
||||
previous = observed;
|
||||
}
|
||||
}
|
||||
public bool IsExpired(TimeSpan timeout) => clock.GetElapsedTime(Volatile.Read(ref lastSeen)) >= timeout;
|
||||
}
|
||||
@@ -27,12 +27,12 @@ internal sealed class TlsControlConnection : IAsyncDisposable
|
||||
public Task Completion { get; }
|
||||
public CancellationToken CancellationToken => lifetime.Token;
|
||||
|
||||
internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken)
|
||||
internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken, TimeSpan? handshakeTimeout = null)
|
||||
{
|
||||
this.socket = socket;
|
||||
this.tls = tls;
|
||||
lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
lifetime.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
lifetime.CancelAfter(handshakeTimeout ?? TimeSpan.FromSeconds(15));
|
||||
Completion = RunAsync();
|
||||
}
|
||||
|
||||
@@ -94,8 +94,8 @@ internal sealed class TlsControlConnection : IAsyncDisposable
|
||||
try { mediaCrypto = new(encryptor, tls.CreateMediaDecryptor()); }
|
||||
catch { encryptor.Dispose(); throw; }
|
||||
mediaReady.SetResult();
|
||||
lifetime.CancelAfter(Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
if (tls.IsReady) lifetime.CancelAfter(TimeSpan.FromSeconds(60));
|
||||
while ((count = tls.ReadPlaintext(plaintext)) > 0) Parse(plaintext.AsSpan(0, count));
|
||||
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
|
||||
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
private readonly IReadOnlyList<Voicecat.V1.Channel> channels;
|
||||
private readonly bool allowGuests;
|
||||
private readonly string name;
|
||||
private readonly VoiceServerOptions options;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly CancellationTokenSource shutdown = new();
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<ulong, Session> sessions = [];
|
||||
@@ -27,6 +29,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
private uint nextUser;
|
||||
private uint nextSsrc;
|
||||
private readonly Task accepting;
|
||||
private readonly Task reaping;
|
||||
private int disposed;
|
||||
|
||||
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
|
||||
@@ -34,9 +37,16 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
public event Action<Exception>? ConnectionFailed;
|
||||
|
||||
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
|
||||
: this(directory, endpoint, new VoiceServerOptions { AllowGuests = allowGuests, Name = name }) { }
|
||||
|
||||
public VoiceServer(string directory, IPEndPoint endpoint, VoiceServerOptions options, TimeProvider? timeProvider = null)
|
||||
{
|
||||
this.allowGuests = allowGuests;
|
||||
this.name = name;
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Validate();
|
||||
this.options = options;
|
||||
clock = timeProvider ?? TimeProvider.System;
|
||||
allowGuests = options.AllowGuests;
|
||||
name = options.Name;
|
||||
credentials = ServerCredentials.LoadOrCreate(directory, name);
|
||||
try
|
||||
{
|
||||
@@ -44,7 +54,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
channels = accounts.LoadChannels();
|
||||
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
listener.Bind(endpoint);
|
||||
listener.Listen(64);
|
||||
listener.Listen(options.MaximumConnections);
|
||||
media = new((IPEndPoint)listener.LocalEndPoint!);
|
||||
media.Failed += exception => ConnectionFailed?.Invoke(exception);
|
||||
}
|
||||
@@ -57,6 +67,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
throw;
|
||||
}
|
||||
accepting = AcceptAsync();
|
||||
reaping = ReapAsync();
|
||||
}
|
||||
|
||||
private async Task AcceptAsync()
|
||||
@@ -68,11 +79,11 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
Socket socket = await listener.AcceptAsync(shutdown.Token).ConfigureAwait(false);
|
||||
lock (gate)
|
||||
{
|
||||
if (sessions.Count >= 64) { socket.Dispose(); continue; }
|
||||
if (sessions.Count >= options.MaximumConnections) { socket.Dispose(); continue; }
|
||||
socket.NoDelay = true;
|
||||
string address = ((IPEndPoint)socket.RemoteEndPoint!).Address.ToString();
|
||||
var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token);
|
||||
var session = new Session(++nextSession, connection, address);
|
||||
var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token, options.HandshakeTimeout);
|
||||
var session = new Session(++nextSession, connection, address, new(clock));
|
||||
sessions.Add(session.Id, session);
|
||||
connections.RemoveAll(task => task.IsCompleted);
|
||||
connections.Add(HandleAsync(session));
|
||||
@@ -88,6 +99,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
{
|
||||
await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false))
|
||||
{
|
||||
session.Activity.Touch();
|
||||
if (envelope.Ping is not null)
|
||||
{
|
||||
session.Connection.TrySend(new() { RequestId = envelope.RequestId, Pong = new() { Nonce = envelope.Ping.Nonce } });
|
||||
@@ -101,7 +113,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
Reject(session, "Unsupported protocol version or banned address.");
|
||||
break;
|
||||
}
|
||||
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false));
|
||||
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false), session.Activity);
|
||||
var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", UdpPort = checked((uint)media.EndPoint.Port), ServerIdentityFingerprint = ByteString.CopyFrom(SHA256.HashData(credentials.Identity.PublicKey)) };
|
||||
if (allowGuests) hello.AuthMethods.Add("guest");
|
||||
hello.AuthMethods.Add("password");
|
||||
@@ -159,6 +171,28 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
session.Connection.CompleteWrites();
|
||||
}
|
||||
|
||||
private async Task ReapAsync()
|
||||
{
|
||||
if (options.IdleTimeout == TimeSpan.Zero) return;
|
||||
using var timer = new PeriodicTimer(options.ReaperInterval, clock);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(shutdown.Token).ConfigureAwait(false))
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
foreach (Session session in sessions.Values)
|
||||
{
|
||||
if (session.Closing || !session.Activity.IsExpired(options.IdleTimeout)) continue;
|
||||
session.Closing = true;
|
||||
Reject(session, "Receive idle timeout.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
|
||||
}
|
||||
|
||||
private async Task AuthenticateAsync(Session session, ulong requestId, AuthRequest request)
|
||||
{
|
||||
User? user = null;
|
||||
@@ -321,23 +355,31 @@ public sealed class VoiceServer : IAsyncDisposable
|
||||
listener.Dispose();
|
||||
try
|
||||
{
|
||||
await accepting.ConfigureAwait(false);
|
||||
Task[] pending;
|
||||
lock (gate) pending = connections.ToArray();
|
||||
await Task.WhenAll(pending).ConfigureAwait(false);
|
||||
await Task.WhenAll(accepting, reaping).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { await media.DisposeAsync().ConfigureAwait(false); }
|
||||
finally { accounts.Dispose(); credentials.Dispose(); shutdown.Dispose(); }
|
||||
try
|
||||
{
|
||||
Task[] pending;
|
||||
lock (gate) pending = connections.ToArray();
|
||||
await Task.WhenAll(pending).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { await media.DisposeAsync().ConfigureAwait(false); }
|
||||
finally { accounts.Dispose(); credentials.Dispose(); shutdown.Dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Session(ulong id, TlsControlConnection connection, string address)
|
||||
private sealed class Session(ulong id, TlsControlConnection connection, string address, SessionActivity activity)
|
||||
{
|
||||
public ulong Id { get; } = id;
|
||||
public TlsControlConnection Connection { get; } = connection;
|
||||
public string Address { get; } = address;
|
||||
public SessionActivity Activity { get; } = activity;
|
||||
public bool Closing { get; set; }
|
||||
public bool HelloReceived { get; set; }
|
||||
public User? User { get; set; }
|
||||
public MediaPeer? Media { get; set; }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace VoiceCat.Server;
|
||||
|
||||
public sealed record VoiceServerOptions
|
||||
{
|
||||
public string Name { get; init; } = "VoiceCat Server";
|
||||
public bool AllowGuests { get; init; } = true;
|
||||
public int MaximumConnections { get; init; } = 64;
|
||||
public TimeSpan HandshakeTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||
public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(45);
|
||||
public TimeSpan ReaperInterval { get; init; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(Name);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(MaximumConnections, 1);
|
||||
if (HandshakeTimeout <= TimeSpan.Zero || HandshakeTimeout.TotalMilliseconds > uint.MaxValue - 1) throw new ArgumentOutOfRangeException(nameof(HandshakeTimeout));
|
||||
if (IdleTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(IdleTimeout));
|
||||
if (ReaperInterval < TimeSpan.Zero || ReaperInterval.TotalMilliseconds > uint.MaxValue - 1 || IdleTimeout > TimeSpan.Zero && ReaperInterval == TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(ReaperInterval));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user