Add encrypted managed UDP relay and native voice conformance
This commit is contained in:
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user