using VoiceCat.Transport; 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 stdout = process.StandardOutput.ReadToEndAsync(); Task 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 output = process.StandardOutput.ReadToEndAsync(); Task 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(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 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 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 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(async () => await udp.ReceiveAsync(new byte[65535], SocketFlags.None, timeout.Token)); } public async ValueTask DisposeAsync() { udp.Dispose(); crypto.Dispose(); await Client.DisposeAsync(); } } }