Add encrypted managed UDP relay and native voice conformance

This commit is contained in:
2026-09-15 22:53:54 +02:00
parent 4067bab7c2
commit 05eacb3092
19 changed files with 1068 additions and 46 deletions
@@ -0,0 +1,50 @@
using System.Net;
using System.Security.Cryptography;
using VoiceCat.Protocol;
namespace VoiceCat.Server.Transport;
// One packet at a time. Each returned buffer must be sent before preparing the next recipient.
internal sealed class MediaFanout : IDisposable
{
private readonly byte[] plaintext = new byte[65535];
private readonly byte[] output = new byte[65535];
private MediaRoute[] routes = [];
private MediaRoute? source;
private VoiceFrameHeader header;
private int length;
private int index;
public bool TryStart(ReadOnlySpan<byte> packet, MediaRoute sender, MediaRoute[] recipients)
{
source = null;
if (!VoiceFrameHeader.TryRead(packet, out var candidate) || candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
!sender.Subscribed || sender.Muted || !sender.Sources.Contains(candidate.Ssrc) ||
packet.Length <= VoiceFrameHeader.Size + 16 || packet.Length > output.Length) return false;
if (!sender.Peer.Crypto.Decryptor.TryDecrypt(packet, plaintext, out header, out length)) return false;
source = sender;
routes = recipients;
index = 0;
return true;
}
public bool TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint)
{
packet = default;
endpoint = null;
if (source is null) return false;
while (index < routes.Length)
{
MediaRoute recipient = routes[index++];
if (ReferenceEquals(recipient.Peer, source.Peer) || recipient.ChannelId != source.ChannelId ||
!recipient.Subscribed || recipient.Deafened || recipient.Peer.Endpoint is null) continue;
int size = recipient.Peer.Crypto.Encryptor.Encrypt(header, plaintext.AsSpan(0, length), output);
packet = output.AsMemory(0, size);
endpoint = recipient.Peer.Endpoint;
return true;
}
return false;
}
public void Dispose() => CryptographicOperations.ZeroMemory(plaintext);
}
@@ -0,0 +1,150 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading.Channels;
using VoiceCat.Protocol;
namespace VoiceCat.Server.Transport;
internal sealed class MediaPeer(byte[] token, MediaSessionCrypto crypto)
{
public byte[] Token { get; } = token;
public MediaSessionCrypto Crypto { get; } = crypto;
// 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); }
}
internal sealed record MediaRoute(MediaPeer Peer, uint ChannelId, bool Subscribed, bool Muted, bool Deafened, uint[] Sources);
internal sealed class MediaRelay : IAsyncDisposable
{
private readonly Socket socket;
private readonly CancellationTokenSource shutdown = new();
private readonly ConcurrentQueue<MediaPeer> retired = new();
private readonly Channel<byte> changed = Channel.CreateBounded<byte>(1);
private MediaRoute[] routes = [];
private readonly byte[] input = new byte[65535];
private readonly MediaFanout fanout = new();
private readonly Task receiving;
public IPEndPoint EndPoint { get; }
public event Action<Exception>? Failed;
public MediaRelay(IPEndPoint endpoint)
{
socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
try { socket.Bind(endpoint); EndPoint = (IPEndPoint)socket.LocalEndPoint!; }
catch { socket.Dispose(); shutdown.Dispose(); throw; }
receiving = ReceiveAsync();
}
// Publications are serialized by the server's session gate. Crypto ownership transfers here.
public void Publish(MediaRoute[] next)
{
MediaRoute[] previous = Volatile.Read(ref routes);
Volatile.Write(ref routes, next);
foreach (MediaRoute route in previous)
if (!next.Any(candidate => ReferenceEquals(candidate.Peer, route.Peer))) retired.Enqueue(route.Peer);
changed.Writer.TryWrite(0);
}
private void DrainRetired()
{
while (retired.TryDequeue(out MediaPeer? peer)) peer.Dispose();
}
private async Task ReceiveAsync()
{
var sender = new SocketAddress(socket.AddressFamily);
Task<int>? receive = null;
Task<bool>? update = null;
try
{
while (true)
{
receive ??= socket.ReceiveFromAsync(input, SocketFlags.None, sender, shutdown.Token).AsTask();
update ??= changed.Reader.WaitToReadAsync(shutdown.Token).AsTask();
await Task.WhenAny(receive, update).ConfigureAwait(false);
if (update.IsCompleted)
{
await update.ConfigureAwait(false);
while (changed.Reader.TryRead(out _)) { }
update = null;
DrainRetired();
}
if (!receive.IsCompleted) continue;
int length;
try { length = await receive.ConfigureAwait(false); }
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.MessageSize or SocketError.ConnectionReset) { continue; }
finally { receive = null; }
DrainRetired();
MediaRoute[] current = Volatile.Read(ref routes);
if (!VoiceFrameHeader.TryRead(input.AsSpan(0, length), out var header)) continue;
MediaRoute? source = null;
foreach (MediaRoute route in current)
if (route.Peer.Endpoint?.Equals(sender) == true) { source = route; break; }
if (header.Type == MediaFrameType.UdpBinding)
{
if (length != VoiceFrameHeader.Size + 16 || source is not null) continue;
foreach (MediaRoute route in current)
{
if (route.Peer.Endpoint is not null || !CryptographicOperations.FixedTimeEquals(route.Peer.Token, input.AsSpan(VoiceFrameHeader.Size, 16))) continue;
var bound = new SocketAddress(sender.Family, sender.Size);
for (int index = 0; index < sender.Size; index++) bound[index] = sender[index];
route.Peer.Endpoint = bound;
break;
}
continue;
}
if (source is null) continue;
if (header.Type == MediaFrameType.Keepalive)
{
if (length == VoiceFrameHeader.Size) await SendAsync(input.AsMemory(0, length), sender).ConfigureAwait(false);
continue;
}
if (!fanout.TryStart(input.AsSpan(0, length), source, current)) continue;
while (fanout.TryNext(out ReadOnlyMemory<byte> packet, out SocketAddress? endpoint))
await SendAsync(packet, endpoint!).ConfigureAwait(false);
}
}
catch (Exception exception) when (shutdown.IsCancellationRequested && exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
catch (Exception exception) { Failed?.Invoke(exception); throw; }
finally
{
shutdown.Cancel();
socket.Dispose();
fanout.Dispose();
if (receive is not null)
{
try { await receive.ConfigureAwait(false); }
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
}
if (update is not null)
{
try { await update.ConfigureAwait(false); }
catch (OperationCanceledException) { }
}
}
}
private async ValueTask SendAsync(ReadOnlyMemory<byte> packet, SocketAddress endpoint)
{
try { await socket.SendToAsync(packet, SocketFlags.None, endpoint, shutdown.Token).ConfigureAwait(false); }
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.HostUnreachable or SocketError.NetworkUnreachable) { }
}
public async ValueTask DisposeAsync()
{
shutdown.Cancel();
socket.Dispose();
try { await receiving.ConfigureAwait(false); }
finally
{
DrainRetired();
foreach (MediaRoute route in Volatile.Read(ref routes)) route.Peer.Dispose();
shutdown.Dispose();
}
}
}
@@ -0,0 +1,10 @@
using VoiceCat.Crypto;
namespace VoiceCat.Server.Transport;
internal sealed class MediaSessionCrypto(MediaEncryptor encryptor, MediaDecryptor decryptor) : IDisposable
{
public MediaEncryptor Encryptor { get; } = encryptor;
public MediaDecryptor Decryptor { get; } = decryptor;
public void Dispose() { Encryptor.Dispose(); Decryptor.Dispose(); }
}
@@ -21,6 +21,8 @@ internal sealed class TlsControlConnection : IAsyncDisposable
private int prefixBytes;
private byte[]? payload;
private int payloadBytes;
private readonly TaskCompletionSource mediaReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private MediaSessionCrypto? mediaCrypto;
public Task Completion { get; }
public CancellationToken CancellationToken => lifetime.Token;
@@ -48,6 +50,12 @@ internal sealed class TlsControlConnection : IAsyncDisposable
public void CompleteWrites() => outgoing.Writer.TryComplete();
internal async Task<MediaSessionCrypto> TakeMediaCryptoAsync(CancellationToken cancellationToken)
{
await mediaReady.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
return Interlocked.Exchange(ref mediaCrypto, null) ?? throw new InvalidOperationException("Media crypto already has an owner.");
}
private async Task RunAsync()
{
byte[] ciphertext = new byte[16384];
@@ -80,6 +88,13 @@ internal sealed class TlsControlConnection : IAsyncDisposable
break;
}
tls.ReceiveCiphertext(ciphertext.AsSpan(0, count));
if (tls.IsReady && !mediaReady.Task.IsCompleted)
{
var encryptor = tls.CreateMediaEncryptor();
try { mediaCrypto = new(encryptor, tls.CreateMediaDecryptor()); }
catch { encryptor.Dispose(); throw; }
mediaReady.SetResult();
}
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);
@@ -104,6 +119,7 @@ internal sealed class TlsControlConnection : IAsyncDisposable
}
finally
{
mediaReady.TrySetCanceled();
lifetime.Cancel();
socket.Dispose();
if (receive is not null)
@@ -163,7 +179,7 @@ internal sealed class TlsControlConnection : IAsyncDisposable
public async ValueTask DisposeAsync()
{
lifetime.Cancel();
await Completion.ConfigureAwait(false);
lifetime.Dispose();
try { await Completion.ConfigureAwait(false); }
finally { Interlocked.Exchange(ref mediaCrypto, null)?.Dispose(); lifetime.Dispose(); }
}
}
+89 -8
View File
@@ -13,6 +13,7 @@ namespace VoiceCat.Server;
public sealed class VoiceServer : IAsyncDisposable
{
private readonly Socket listener;
private readonly MediaRelay media;
private readonly ServerCredentials credentials;
private readonly AccountStore accounts;
private readonly IReadOnlyList<Voicecat.V1.Channel> channels;
@@ -24,10 +25,12 @@ public sealed class VoiceServer : IAsyncDisposable
private readonly List<Task> connections = [];
private ulong nextSession;
private uint nextUser;
private uint nextSsrc;
private readonly Task accepting;
private int disposed;
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
public IPEndPoint MediaEndPoint => media.EndPoint;
public event Action<Exception>? ConnectionFailed;
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
@@ -42,6 +45,8 @@ public sealed class VoiceServer : IAsyncDisposable
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(endpoint);
listener.Listen(64);
media = new((IPEndPoint)listener.LocalEndPoint!);
media.Failed += exception => ConnectionFailed?.Invoke(exception);
}
catch
{
@@ -96,7 +101,8 @@ public sealed class VoiceServer : IAsyncDisposable
Reject(session, "Unsupported protocol version or banned address.");
break;
}
var hello = new ServerHello { ProtoVersion = 2, ServerName = name, ServerVersion = "0.1.0-dotnet", ServerIdentityFingerprint = ByteString.CopyFrom(SHA256.HashData(credentials.Identity.PublicKey)) };
session.Media = new(RandomNumberGenerator.GetBytes(16), await session.Connection.TakeMediaCryptoAsync(shutdown.Token).ConfigureAwait(false));
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");
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
@@ -114,8 +120,14 @@ public sealed class VoiceServer : IAsyncDisposable
case Envelope.BodyOneofCase.TextMessage: RelayText(session, envelope.TextMessage); break;
case Envelope.BodyOneofCase.Subscribe: SendSnapshot(session); break;
case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId); break;
case Envelope.BodyOneofCase.SubscribeVoice:
session.Connection.TrySend(new() { RequestId = envelope.RequestId, VoiceSubscriptionResult = new() { Error = "Managed media relay is not implemented yet." } });
case Envelope.BodyOneofCase.SubscribeVoice: SubscribeVoice(session, envelope.RequestId, true); break;
case Envelope.BodyOneofCase.UnsubscribeVoice: SubscribeVoice(session, envelope.RequestId, false); break;
case Envelope.BodyOneofCase.StreamAnnounce: AnnounceStream(session, envelope.RequestId, envelope.StreamAnnounce); break;
case Envelope.BodyOneofCase.StreamStop: StopStream(session, envelope.StreamStop.StreamId); break;
case Envelope.BodyOneofCase.StreamState: UpdateStream(session, envelope.StreamState); break;
case Envelope.BodyOneofCase.UdpBinding:
if (!envelope.UdpBinding.Ack && CryptographicOperations.FixedTimeEquals(envelope.UdpBinding.UdpToken.Span, session.Media!.Token))
session.Connection.TrySend(new() { RequestId = envelope.RequestId, UdpBinding = new() { Ack = true } });
break;
default:
session.Connection.TrySend(new() { RequestId = envelope.RequestId, GenericResult = new() { Code = 1, Message = "Operation is not implemented by this server checkpoint." } });
@@ -133,6 +145,8 @@ public sealed class VoiceServer : IAsyncDisposable
lock (gate)
{
sessions.Remove(session.Id);
if (session.User is null) session.Media?.Dispose();
else PublishMedia();
if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id } });
}
await session.Connection.DisposeAsync().ConfigureAwait(false);
@@ -170,9 +184,10 @@ public sealed class VoiceServer : IAsyncDisposable
session.User = user;
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new()
{
Ok = true, SessionId = session.Id, Self = user.Clone(),
Ok = true, SessionId = session.Id, Self = user.Clone(), UdpToken = ByteString.CopyFrom(session.Media!.Token),
Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin }
} });
PublishMedia();
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id);
SendSnapshot(session);
}
@@ -199,7 +214,9 @@ public sealed class VoiceServer : IAsyncDisposable
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = new() { Error = "Channel unavailable." } });
return;
}
session.User!.ChannelId = channelId;
if (session.User!.ChannelId != channelId) session.User.Streams.Clear();
session.User.ChannelId = channelId;
PublishMedia();
var result = new JoinChannelResult { Ok = true, ChannelId = channelId, Audio = channel.Audio.Clone() };
result.Members.Add(sessions.Values.Where(peer => peer.User?.ChannelId == channelId).Select(peer => peer.User!.Clone()));
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = result });
@@ -229,6 +246,69 @@ public sealed class VoiceServer : IAsyncDisposable
}
}
private void PublishMedia()
{
media.Publish(sessions.Values.Where(peer => peer.User is not null).Select(peer => new MediaRoute(
peer.Media!, peer.User!.ChannelId, peer.User.VoiceSubscribed, peer.User.ServerMuted, peer.User.SelfDeafened || peer.User.ServerDeafened,
peer.User.Streams.Select(stream => stream.Ssrc).ToArray())).ToArray());
}
private void BroadcastUser(Session session) => Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User!.Clone() } });
private void SubscribeVoice(Session session, ulong requestId, bool subscribed)
{
lock (gate)
{
session.User!.VoiceSubscribed = subscribed;
if (!subscribed) session.User.Streams.Clear();
PublishMedia();
session.Connection.TrySend(new() { RequestId = requestId, VoiceSubscriptionResult = new() { Ok = true, Subscribed = subscribed } });
BroadcastUser(session);
}
}
private void AnnounceStream(Session session, ulong requestId, StreamAnnounce request)
{
lock (gate)
{
if (!session.User!.VoiceSubscribed || !Enum.IsDefined(request.Kind) || request.Label.Length > 128 || session.User.Streams.Count >= 16 ||
nextSsrc == uint.MaxValue || session.NextStream == uint.MaxValue || request.RequestedAudio?.BitrateBps is > 0 and < 500)
{
session.Connection.TrySend(new() { RequestId = requestId, StreamAnnounceResult = new() { Error = "Voice subscription required, invalid stream, or stream limit reached." } });
return;
}
AudioConfig audio = channels.First(channel => channel.Id == session.User.ChannelId).Audio.Clone();
if (request.RequestedAudio?.BitrateBps > 0) audio.BitrateBps = Math.Min(audio.BitrateBps, request.RequestedAudio.BitrateBps);
var stream = new StreamInfo { StreamId = ++session.NextStream, Ssrc = ++nextSsrc, Kind = request.Kind, Label = request.Label, Audio = audio };
session.User.Streams.Add(stream);
PublishMedia();
session.Connection.TrySend(new() { RequestId = requestId, StreamAnnounceResult = new() { Ok = true, StreamId = stream.StreamId, Ssrc = stream.Ssrc, EffectiveAudio = audio.Clone() } });
BroadcastUser(session);
}
}
private void StopStream(Session session, uint streamId)
{
lock (gate)
{
StreamInfo? stream = session.User!.Streams.FirstOrDefault(candidate => candidate.StreamId == streamId);
if (stream is null) return;
session.User.Streams.Remove(stream);
PublishMedia();
BroadcastUser(session);
}
}
private void UpdateStream(Session session, StreamStateUpdate update)
{
lock (gate)
{
StreamInfo? stream = session.User!.Streams.FirstOrDefault(candidate => candidate.StreamId == update.StreamId);
if (stream is null) return;
Broadcast(new() { StreamState = new() { UserId = session.User.Id, StreamId = stream.StreamId, Muted = update.Muted, Talking = update.Talking } });
}
}
private void Broadcast(Envelope envelope, ulong excluded = 0)
{
foreach (Session recipient in sessions.Values.Where(peer => peer.Id != excluded && peer.User is not null)) recipient.Connection.TrySend(envelope);
@@ -248,9 +328,8 @@ public sealed class VoiceServer : IAsyncDisposable
}
finally
{
accounts.Dispose();
credentials.Dispose();
shutdown.Dispose();
try { await media.DisposeAsync().ConfigureAwait(false); }
finally { accounts.Dispose(); credentials.Dispose(); shutdown.Dispose(); }
}
}
@@ -261,5 +340,7 @@ public sealed class VoiceServer : IAsyncDisposable
public string Address { get; } = address;
public bool HelloReceived { get; set; }
public User? User { get; set; }
public MediaPeer? Media { get; set; }
public uint NextStream;
}
}