Add encrypted managed UDP relay and native voice conformance
This commit is contained in:
+24
-2
@@ -137,5 +137,27 @@ managed code imports and authenticates it, then C++ authenticates a managed-crea
|
||||
account. CI also regenerates the libsodium password fixture. Native checks require
|
||||
the optional `VOICECAT_BUILD_DOTNET_ORACLE=ON` configure flag and a real-deps build.
|
||||
|
||||
Phase 4 remains in progress: UDP/SFU relay, streams, protected channel joins,
|
||||
administration, moderation, and production configuration are the next server work.
|
||||
The server also advertises UDP on the TCP port number, supports voice subscription
|
||||
and stream signaling, and reseals encoded audio for subscribers in the same channel.
|
||||
UDP binding fixes the first endpoint for the session; reconnect after endpoint changes.
|
||||
Protected joins, administration, moderation, production configuration and full
|
||||
media-aware reaping remain before Phase 4 completion.
|
||||
|
||||
Enable deterministic native voice interoperability (no audio hardware required):
|
||||
|
||||
```powershell
|
||||
cmake --build --preset dev --target voicecat-dotnet-voice-oracle
|
||||
$env:VOICECAT_VOICE_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-voice-oracle.exe).Path
|
||||
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
Two existing C++ clients authenticate, join Lobby or Music Room, publish three
|
||||
concurrent streams, feed PCM, and verify decoded energy and metadata in both directions.
|
||||
The native clients use external capture/playback to avoid device dependencies in CI.
|
||||
`MediaFanoutTests` separately verifies 50-subscriber routing/resealing without managed
|
||||
allocations after warm-up and reports throughput; socket scheduling is excluded.
|
||||
The transport load test delivers all 2,500 recipient packets from a paced 50 pps sender.
|
||||
Native `vccli --test-tone-ms 4000` runs finite external capture/playback, feeds a tone,
|
||||
and fails without decoded remote audio. Tests start two CLI processes in mono/stereo
|
||||
channels and also verify channel text. Normal `--voice` now explicitly subscribes before
|
||||
announcing its microphone stream. No C ABI or wire changes were needed.
|
||||
|
||||
@@ -8,6 +8,10 @@ target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-voice-oracle voice.cpp)
|
||||
target_link_libraries(voicecat-dotnet-voice-oracle PRIVATE voicecat::voicecat)
|
||||
target_compile_features(voicecat-dotnet-voice-oracle PRIVATE cxx_std_20)
|
||||
|
||||
add_executable(voicecat-dotnet-dsp-oracle dsp.cpp)
|
||||
target_link_libraries(voicecat-dotnet-dsp-oracle PRIVATE voicecat::voicecat)
|
||||
target_include_directories(voicecat-dotnet-dsp-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#include "voicecat.h"
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
struct ClientState {
|
||||
vc_client* client = nullptr;
|
||||
std::mutex gate;
|
||||
std::condition_variable changed;
|
||||
bool authenticated = false;
|
||||
bool subscribed = false;
|
||||
bool joined = false;
|
||||
uint32_t user = 0;
|
||||
std::vector<std::pair<uint32_t, uint32_t>> streams;
|
||||
std::array<int, 3> received{};
|
||||
long long energy = 0;
|
||||
uint32_t channels = 0;
|
||||
};
|
||||
|
||||
static void event(void* context, const vc_event* value) {
|
||||
auto& state = *static_cast<ClientState*>(context);
|
||||
if (value->type == VC_EVENT_SERVER_IDENTITY) {
|
||||
vc_confirm_server_identity(state.client, 1);
|
||||
return;
|
||||
}
|
||||
std::lock_guard lock(state.gate);
|
||||
switch (value->type) {
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
state.authenticated = value->result == VC_OK;
|
||||
state.user = value->user_id;
|
||||
break;
|
||||
case VC_EVENT_VOICE_STATE: state.subscribed = value->u32a == 1; break;
|
||||
case VC_EVENT_JOIN_RESULT: state.joined = value->result == VC_OK; break;
|
||||
case VC_EVENT_STREAM_STARTED: state.streams.emplace_back(value->user_id, value->stream_id); break;
|
||||
default: break;
|
||||
}
|
||||
state.changed.notify_all();
|
||||
}
|
||||
|
||||
static void sink(void* context, uint32_t, uint32_t stream, const int16_t* pcm,
|
||||
size_t samples, uint32_t channels, uint32_t rate) {
|
||||
auto& state = *static_cast<ClientState*>(context);
|
||||
if (rate != 48000 || stream >= state.received.size()) return;
|
||||
std::lock_guard lock(state.gate);
|
||||
++state.received[stream];
|
||||
state.channels = channels;
|
||||
for (size_t index = 0; index < samples * channels; ++index) state.energy += std::abs(static_cast<int>(pcm[index]));
|
||||
state.changed.notify_all();
|
||||
}
|
||||
|
||||
template<class Predicate>
|
||||
static bool wait(ClientState& state, Predicate predicate) {
|
||||
std::unique_lock lock(state.gate);
|
||||
return state.changed.wait_for(lock, std::chrono::seconds(8), predicate);
|
||||
}
|
||||
|
||||
struct Destroy {
|
||||
void operator()(vc_client* client) const { vc_disconnect(client); vc_client_destroy(client); }
|
||||
};
|
||||
using Client = std::unique_ptr<vc_client, Destroy>;
|
||||
|
||||
static Client connect(ClientState& state, uint16_t port, uint32_t channel, const char* nickname) {
|
||||
vc_config config{"dotnet-voice-oracle", "1", VC_LOG_OFF};
|
||||
Client client(vc_client_create(&config, {event, nullptr, &state}));
|
||||
state.client = client.get();
|
||||
if (!client || vc_set_external_playback(client.get(), 1) != VC_OK ||
|
||||
vc_connect(client.get(), "127.0.0.1", port) != VC_OK ||
|
||||
vc_authenticate_guest(client.get(), nickname) != VC_OK ||
|
||||
!wait(state, [&] { return state.authenticated; }) ||
|
||||
vc_join_channel(client.get(), channel, nullptr) != VC_OK ||
|
||||
!wait(state, [&] { return state.joined; }) ||
|
||||
vc_join_voice(client.get()) != VC_OK ||
|
||||
!wait(state, [&] { return state.subscribed; }) ||
|
||||
vc_set_pcm_sink(client.get(), sink, &state) != VC_OK) return {};
|
||||
return client;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 3) return 1;
|
||||
uint16_t port = static_cast<uint16_t>(std::strtoul(argv[1], nullptr, 10));
|
||||
uint32_t channel = static_cast<uint32_t>(std::strtoul(argv[2], nullptr, 10));
|
||||
ClientState alice, bob;
|
||||
Client a = connect(alice, port, channel, "Native Alice");
|
||||
Client b = connect(bob, port, channel, "Native Bob");
|
||||
if (!a || !b) { std::fprintf(stderr, "native authentication/join/subscription failed\n"); return 1; }
|
||||
std::array<uint32_t, 3> ids{};
|
||||
vc_stream_desc mic{};
|
||||
mic.kind = VC_STREAM_MIC;
|
||||
mic.external_feed = 1;
|
||||
vc_stream_desc screen = mic;
|
||||
screen.kind = VC_STREAM_SCREEN_AUDIO;
|
||||
if (vc_stream_start(a.get(), &mic, &ids[0]) != VC_OK ||
|
||||
vc_stream_start(a.get(), &screen, &ids[1]) != VC_OK ||
|
||||
vc_stream_start(b.get(), &mic, &ids[2]) != VC_OK ||
|
||||
!wait(alice, [&] { return alice.streams.size() >= 3; }) ||
|
||||
!wait(bob, [&] { return bob.streams.size() >= 3; })) {
|
||||
std::fprintf(stderr, "native stream signaling failed\n"); return 1;
|
||||
}
|
||||
uint32_t channels = channel == 2 ? 2 : 1;
|
||||
std::vector<int16_t> pcm(960 * channels);
|
||||
for (size_t sample = 0; sample < 960; ++sample)
|
||||
for (uint32_t side = 0; side < channels; ++side)
|
||||
pcm[sample * channels + side] = static_cast<int16_t>(12000 * std::sin(sample * (side == 0 ? 0.058 : 0.083)));
|
||||
for (int frame = 0; frame < 100; ++frame) {
|
||||
if (vc_stream_feed_pcm(a.get(), ids[0], pcm.data(), 960, channels) != VC_OK ||
|
||||
vc_stream_feed_pcm(a.get(), ids[1], pcm.data(), 960, channels) != VC_OK ||
|
||||
vc_stream_feed_pcm(b.get(), ids[2], pcm.data(), 960, channels) != VC_OK) return 1;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
bool received = wait(alice, [&] { return alice.received[ids[2]] >= 5 && alice.energy > 0; }) &&
|
||||
wait(bob, [&] { return bob.received[ids[0]] >= 5 && bob.received[ids[1]] >= 5 && bob.energy > 0; });
|
||||
{
|
||||
std::scoped_lock lock(alice.gate, bob.gate);
|
||||
std::printf("channel=%u channels=%u alice=%d bob-mic=%d bob-screen=%d energy=%lld/%lld\n",
|
||||
channel, channels, alice.received[ids[2]], bob.received[ids[0]], bob.received[ids[1]], alice.energy, bob.energy);
|
||||
received = received && alice.channels == channels && bob.channels == channels;
|
||||
}
|
||||
return received ? 0 : 1;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Server.Transport;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class MediaFanoutTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task UdpRelayDeliversFiftyPacketsPerSecondToFiftySubscribers()
|
||||
{
|
||||
await using var relay = new MediaRelay(new(IPAddress.Loopback, 0));
|
||||
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||
Socket[] sockets = Enumerable.Range(0, 51).Select(_ => new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)).ToArray();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
try
|
||||
{
|
||||
foreach (Socket socket in sockets)
|
||||
{
|
||||
socket.ReceiveBufferSize = 1024 * 1024;
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
}
|
||||
MediaRoute[] routes = sockets.Select(socket => new MediaRoute(
|
||||
new(new byte[16], new(new(key), new(key))) { Endpoint = ((IPEndPoint)socket.LocalEndPoint!).Serialize() },
|
||||
1, true, false, false, [42])).ToArray();
|
||||
relay.Publish(routes);
|
||||
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
|
||||
Task[] receivers = sockets.Skip(1).Select(async socket =>
|
||||
{
|
||||
using var decryptor = new MediaDecryptor(key);
|
||||
byte[] packet = new byte[4000];
|
||||
byte[] decoded = new byte[4000];
|
||||
for (ulong sequence = 0; sequence < 50; sequence++)
|
||||
{
|
||||
int length = await socket.ReceiveAsync(packet, SocketFlags.None, timeout.Token);
|
||||
Assert.True(decryptor.TryDecrypt(packet.AsSpan(0, length), decoded, out var header, out int bytes));
|
||||
Assert.Equal(sequence, header.Sequence);
|
||||
Assert.Equal(payload, decoded[..bytes]);
|
||||
}
|
||||
}).ToArray();
|
||||
using var encryptor = new MediaEncryptor(key);
|
||||
byte[] outgoing = new byte[VoiceFrameHeader.Size + payload.Length + 16];
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
for (uint index = 0; index < 50; index++)
|
||||
{
|
||||
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, index * 960), payload, outgoing);
|
||||
await sockets[0].SendToAsync(outgoing, SocketFlags.None, relay.EndPoint, timeout.Token);
|
||||
await Task.Delay(20, timeout.Token);
|
||||
}
|
||||
await Task.WhenAll(receivers);
|
||||
output.WriteLine($"Delivered all 2,500 recipient packets in {elapsed.Elapsed.TotalMilliseconds:F1} ms at a paced 50 pps input.");
|
||||
}
|
||||
finally { foreach (Socket socket in sockets) socket.Dispose(); }
|
||||
}
|
||||
|
||||
[PlatformCipherFact]
|
||||
public void FiftySubscriberFanoutAllocatesNoManagedMemoryAndPreservesPayload()
|
||||
{
|
||||
byte[] key = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||
MediaRoute[] routes = Enumerable.Range(0, 51).Select(i => new MediaRoute(
|
||||
new(new byte[16], new(new(key), new(key))) { Endpoint = new IPEndPoint(IPAddress.Loopback, 10000 + i).Serialize() },
|
||||
1, true, false, false, [42])).ToArray();
|
||||
using var sender = new MediaEncryptor(key);
|
||||
using var receiver = new MediaDecryptor(key);
|
||||
using var fanout = new MediaFanout();
|
||||
byte[] payload = Enumerable.Range(0, 120).Select(i => (byte)i).ToArray();
|
||||
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
|
||||
byte[] decoded = new byte[payload.Length];
|
||||
ReadOnlyMemory<byte> last = default;
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < 100; i++) Cycle();
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
for (int i = 0; i < 1000; i++) Cycle();
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
Assert.Equal(0, allocated);
|
||||
Assert.True(receiver.TryDecrypt(last.Span, decoded, out var header, out int length));
|
||||
Assert.Equal(payload.Length, length);
|
||||
Assert.Equal(payload, decoded);
|
||||
Assert.Equal(42U, header.Ssrc);
|
||||
Assert.Equal(1099UL, header.Sequence);
|
||||
output.WriteLine($"50,000 recipient seals in {elapsed.TotalMilliseconds:F1} ms; {allocated} managed bytes. Transport scheduling is excluded.");
|
||||
}
|
||||
finally { foreach (MediaRoute route in routes) route.Peer.Dispose(); }
|
||||
|
||||
void Cycle()
|
||||
{
|
||||
sender.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), payload, packet);
|
||||
if (!fanout.TryStart(packet, routes[0], routes)) throw new InvalidOperationException("Valid packet rejected.");
|
||||
int recipients = 0;
|
||||
while (fanout.TryNext(out var next, out _)) { last = next; recipients++; }
|
||||
if (recipients != 50) throw new InvalidOperationException("Incorrect fanout.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PlatformCipherFactAttribute : FactAttribute
|
||||
{
|
||||
public PlatformCipherFactAttribute()
|
||||
{
|
||||
if (!ChaCha20Poly1305.IsSupported) Skip = "The allocation guarantee requires platform ChaCha20-Poly1305; fallback conformance is tested separately.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Net;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Server.Transport;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
public sealed class MediaRelayTests
|
||||
{
|
||||
[CppCliVoiceTheory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task TwoCppCliProcessesJoinChatAndExchangeVoice(int channel)
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var observer = await fixture.ConnectAsync();
|
||||
await observer.LoginAsync("Observer");
|
||||
observer.Send(new() { JoinChannel = new() { ChannelId = checked((uint)channel) } });
|
||||
Assert.True((await observer.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await Task.WhenAll(RunAsync("Cli Alice"), RunAsync("Cli Bob"));
|
||||
var first = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||
var second = (await observer.ReadUntilAsync(e => e.TextMessage is not null)).TextMessage;
|
||||
Assert.Equal("CLI voice checkpoint", first.Body);
|
||||
Assert.Equal(first.Body, second.Body);
|
||||
Assert.NotEqual(first.SenderId, second.SenderId);
|
||||
|
||||
async Task RunAsync(string nickname)
|
||||
{
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true
|
||||
};
|
||||
foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"--nick", nickname, "--channel", channel.ToString(System.Globalization.CultureInfo.InvariantCulture), "--text", "CLI voice checkpoint", "--test-tone-ms", "4000" })
|
||||
start.ArgumentList.Add(argument);
|
||||
using var process = Process.Start(start)!;
|
||||
Task<string> stdout = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> stderr = process.StandardError.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
string log = await stdout + await stderr;
|
||||
Assert.True(process.ExitCode == 0, log);
|
||||
Assert.Contains("[test-tone] received=", log);
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CppCliVoiceTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public CppCliVoiceTheoryAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI.";
|
||||
}
|
||||
}
|
||||
|
||||
[VoiceOracleTheory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task ExistingCppClientsExchangeBidirectionalVoiceThroughManagedServer(int channel)
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE")!)
|
||||
{
|
||||
WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true
|
||||
};
|
||||
start.ArgumentList.Add(fixture.Server.EndPoint.Port.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
start.ArgumentList.Add(channel.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
using var process = Process.Start(start)!;
|
||||
Task<string> output = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> error = process.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await output + await error);
|
||||
}
|
||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
||||
}
|
||||
|
||||
private sealed class VoiceOracleTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public VoiceOracleTheoryAttribute()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VOICE_ORACLE"))) Skip = "Set VOICECAT_VOICE_ORACLE to the native voice conformance executable.";
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectInvalidatesBothBindingAndActiveStreams()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
var stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
alice.Client.Send(new() { Disconnect = new() });
|
||||
await bob.Client.ReadUntilAsync(e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left && e.UserEvent.LeftId == alice.Client.Authentication!.Self.Id);
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [1]));
|
||||
await bob.AssertNoVoiceAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnounceRequiresSubscriptionAndUsesAuthoritativeMusicSettings()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var client = await fixture.ConnectAsync();
|
||||
await client.LoginAsync("Alice");
|
||||
client.Send(new() { StreamAnnounce = new() { Kind = StreamKind.StreamMic } });
|
||||
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
|
||||
client.Send(new() { SubscribeVoice = new() });
|
||||
await client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null);
|
||||
client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
|
||||
await client.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||
client.Send(new() { RequestId = 21, StreamAnnounce = new() { Kind = StreamKind.StreamScreenAudio, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 64000 } } });
|
||||
var announced = await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null);
|
||||
Assert.Equal(21UL, announced.RequestId);
|
||||
Assert.True(announced.StreamAnnounceResult.Ok);
|
||||
Assert.Equal(64000U, announced.StreamAnnounceResult.EffectiveAudio.BitrateBps);
|
||||
Assert.Equal(48000U, announced.StreamAnnounceResult.EffectiveAudio.SampleRate);
|
||||
Assert.Equal(ChannelMode.ModeStereo, announced.StreamAnnounceResult.EffectiveAudio.Mode);
|
||||
client.Send(new() { StreamAnnounce = new() { Kind = (StreamKind)99 } });
|
||||
Assert.False((await client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EncryptedOpusIsResealedWithRecipientCountersAcrossMultipleStreamsAndSenders()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
await using var carol = await VoicePeer.ConnectAsync(fixture, "Carol");
|
||||
StreamAnnounceResult mic = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
StreamAnnounceResult screen = await alice.AnnounceAsync(StreamKind.StreamScreenAudio);
|
||||
StreamAnnounceResult other = await carol.AnnounceAsync(StreamKind.StreamMic);
|
||||
Assert.NotEqual(mic.StreamId, screen.StreamId);
|
||||
Assert.NotEqual(mic.Ssrc, screen.Ssrc);
|
||||
Assert.Equal(48000U, screen.EffectiveAudio.SampleRate);
|
||||
Assert.Equal(24000U, screen.EffectiveAudio.BitrateBps);
|
||||
using var encoder = new Codec.OpusEncoder(new());
|
||||
short[] samples = Enumerable.Range(0, 960).Select(i => (short)(8000 * Math.Sin(i * 0.1))).ToArray();
|
||||
byte[] payload = new byte[4000];
|
||||
int length = encoder.Encode(samples, payload);
|
||||
payload = payload[..length];
|
||||
foreach (var (sender, stream) in new[] { (alice, mic), (carol, other), (alice, screen) })
|
||||
{
|
||||
byte[] packet = sender.Seal(stream.Ssrc, payload, 960, VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent);
|
||||
await sender.SendAsync(packet);
|
||||
var received = await bob.ReceiveVoiceAsync();
|
||||
Assert.Equal(payload, received.Payload);
|
||||
Assert.Equal(stream.Ssrc, received.Header.Ssrc);
|
||||
Assert.Equal(960U, received.Header.Timestamp);
|
||||
Assert.Equal(VoiceFrameFlags.Marker | VoiceFrameFlags.FecPresent, received.Header.Flags);
|
||||
}
|
||||
Assert.Equal(2UL, bob.LastSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayForgeryAndSpoofedStreamsAreDroppedWithoutBreakingValidMedia()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
byte[] packet = alice.Seal(stream.Ssrc, [1, 2, 3]);
|
||||
await alice.SendAsync(packet);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||
await alice.SendAsync(packet);
|
||||
byte[] forged = alice.Seal(stream.Ssrc, [4]);
|
||||
forged[^1] ^= 1;
|
||||
await alice.SendAsync(forged);
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc + 1000, [5]));
|
||||
await alice.SendAsync([1]);
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [6]));
|
||||
Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||
Assert.Equal(1UL, bob.LastSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscriptionChannelMovementAndStreamStopIsolateMedia()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
var mic = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
await bob.SubscribeAsync(false);
|
||||
await alice.SendAsync(alice.Seal(mic.Ssrc, [1]));
|
||||
await bob.AssertNoVoiceAsync();
|
||||
await bob.SubscribeAsync(true);
|
||||
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 2 } });
|
||||
Assert.True((await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok);
|
||||
await alice.SendAsync(alice.Seal(mic.Ssrc, [2]));
|
||||
await bob.AssertNoVoiceAsync();
|
||||
bob.Client.Send(new() { JoinChannel = new() { ChannelId = 1 } });
|
||||
await bob.Client.ReadUntilAsync(e => e.JoinChannelResult is not null);
|
||||
alice.Client.Send(new() { StreamStop = new() { StreamId = mic.StreamId } });
|
||||
await alice.Client.ReadUntilAsync(e => e.UserEvent?.User?.Id == alice.Client.Authentication!.Self.Id && e.UserEvent.User.Streams.Count == 0);
|
||||
await alice.SendAsync(alice.Seal(mic.Ssrc, [3]));
|
||||
await bob.AssertNoVoiceAsync();
|
||||
var replacement = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
await alice.SendAsync(alice.Seal(replacement.Ssrc, [4]));
|
||||
Assert.Equal(new byte[] { 4 }, (await bob.ReceiveVoiceAsync()).Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BadTokensCannotBindAndExistingBindingCannotBeStolen()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
using var rogue = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
rogue.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||
alice.Client.Authentication!.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||
await rogue.SendToAsync(binding, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
await rogue.SendToAsync(keepalive, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||
using var timeout = new CancellationTokenSource(200);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await rogue.ReceiveAsync(new byte[100], SocketFlags.None, timeout.Token));
|
||||
await alice.SendAsync(keepalive);
|
||||
Assert.Equal(keepalive, await alice.ReceivePacketAsync());
|
||||
}
|
||||
|
||||
internal sealed class VoicePeer : IAsyncDisposable
|
||||
{
|
||||
public Client Client { get; }
|
||||
private readonly Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
private readonly IPEndPoint endpoint;
|
||||
private readonly MediaSessionCrypto crypto;
|
||||
public ulong LastSequence { get; private set; }
|
||||
private VoicePeer(Client client, IPEndPoint endpoint, MediaSessionCrypto crypto)
|
||||
{
|
||||
Client = client; this.endpoint = endpoint; this.crypto = crypto;
|
||||
udp.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
}
|
||||
public static async Task<VoicePeer> ConnectAsync(ServerFixture fixture, string nickname)
|
||||
{
|
||||
Client client = await fixture.ConnectAsync();
|
||||
await client.LoginAsync(nickname);
|
||||
var peer = new VoicePeer(client, fixture.Server.MediaEndPoint, await client.TakeMediaCryptoAsync());
|
||||
client.Send(new() { UdpBinding = new() { UdpToken = client.Authentication!.UdpToken } });
|
||||
Assert.True((await client.ReadUntilAsync(e => e.UdpBinding is not null)).UdpBinding.Ack);
|
||||
byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||
client.Authentication.UdpToken.Span.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||
await peer.SendAsync(binding);
|
||||
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
await peer.SendAsync(keepalive);
|
||||
Assert.Equal(keepalive, await peer.ReceivePacketAsync());
|
||||
await peer.SubscribeAsync(true);
|
||||
return peer;
|
||||
}
|
||||
public async Task SubscribeAsync(bool subscribed)
|
||||
{
|
||||
Client.Send(subscribed ? new() { SubscribeVoice = new() } : new() { UnsubscribeVoice = new() });
|
||||
var result = (await Client.ReadUntilAsync(e => e.VoiceSubscriptionResult is not null)).VoiceSubscriptionResult;
|
||||
Assert.True(result.Ok); Assert.Equal(subscribed, result.Subscribed);
|
||||
}
|
||||
public async Task<StreamAnnounceResult> AnnounceAsync(StreamKind kind)
|
||||
{
|
||||
Client.Send(new() { StreamAnnounce = new() { Kind = kind, RequestedAudio = new() { SampleRate = 8000, BitrateBps = 900000 } } });
|
||||
var result = (await Client.ReadUntilAsync(e => e.StreamAnnounceResult is not null)).StreamAnnounceResult;
|
||||
Assert.True(result.Ok, result.Error);
|
||||
return result;
|
||||
}
|
||||
public byte[] Seal(uint ssrc, byte[] payload, uint timestamp = 0, VoiceFrameFlags flags = 0)
|
||||
{
|
||||
byte[] packet = new byte[VoiceFrameHeader.Size + payload.Length + 16];
|
||||
crypto.Encryptor.Encrypt(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload, packet);
|
||||
return packet;
|
||||
}
|
||||
public async Task SendAsync(byte[] packet) => await udp.SendToAsync(packet, SocketFlags.None, endpoint, Client.Timeout.Token);
|
||||
public async Task<byte[]> ReceivePacketAsync()
|
||||
{
|
||||
byte[] buffer = new byte[65535];
|
||||
int size = await udp.ReceiveAsync(buffer, SocketFlags.None, Client.Timeout.Token);
|
||||
return buffer[..size];
|
||||
}
|
||||
public async Task<(VoiceFrameHeader Header, byte[] Payload)> ReceiveVoiceAsync()
|
||||
{
|
||||
byte[] packet = await ReceivePacketAsync();
|
||||
byte[] plain = new byte[65535];
|
||||
Assert.True(crypto.Decryptor.TryDecrypt(packet, plain, out var header, out int length));
|
||||
LastSequence = header.Sequence;
|
||||
return (header, plain[..length]);
|
||||
}
|
||||
public async Task AssertNoVoiceAsync()
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(200);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await udp.ReceiveAsync(new byte[65535], SocketFlags.None, timeout.Token));
|
||||
}
|
||||
public async ValueTask DisposeAsync() { udp.Dispose(); crypto.Dispose(); await Client.DisposeAsync(); }
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ public sealed class ServerTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ServerFixture : IAsyncDisposable
|
||||
internal sealed class ServerFixture : IAsyncDisposable
|
||||
{
|
||||
public string Directory { get; } = Path.Combine(Path.GetTempPath(), "voicecat-server-" + Guid.NewGuid().ToString("N"));
|
||||
public VoiceServer Server { get; }
|
||||
@@ -156,7 +156,7 @@ public sealed class ServerTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Client : IAsyncDisposable
|
||||
internal sealed class Client : IAsyncDisposable
|
||||
{
|
||||
public CancellationTokenSource Timeout { get; } = new(TimeSpan.FromSeconds(30));
|
||||
private readonly TlsControlConnection connection;
|
||||
@@ -167,6 +167,8 @@ public sealed class ServerTests
|
||||
messages = connection.ReadAsync(Timeout.Token).GetAsyncEnumerator();
|
||||
}
|
||||
public void Send(Envelope envelope) => Assert.True(connection.TrySend(envelope));
|
||||
public AuthResult? Authentication { get; private set; }
|
||||
public Task<MediaSessionCrypto> TakeMediaCryptoAsync() => connection.TakeMediaCryptoAsync(Timeout.Token);
|
||||
public async Task<Envelope> ReadUntilAsync(Func<Envelope, bool> predicate)
|
||||
{
|
||||
while (await messages.MoveNextAsync()) if (predicate(messages.Current)) return messages.Current;
|
||||
@@ -178,6 +180,7 @@ public sealed class ServerTests
|
||||
Assert.Equal(1UL, (await ReadUntilAsync(e => e.ServerHello is not null)).RequestId);
|
||||
Send(new() { RequestId = 2, AuthRequest = new() { Guest = new() { Nickname = nickname } } });
|
||||
AuthResult auth = (await ReadUntilAsync(e => e.AuthResult is not null)).AuthResult;
|
||||
Authentication = auth;
|
||||
Assert.True(auth.Ok, auth.Error);
|
||||
ServerStateSnapshot state = (await ReadUntilAsync(e => e.ServerState is not null)).ServerState;
|
||||
Assert.Equal(2, state.Channels.Count);
|
||||
|
||||
Reference in New Issue
Block a user