Add managed codec DSP and initial control server
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
internal sealed class OpusEncoderHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public OpusEncoderHandle() : base(true) { }
|
||||
internal OpusEncoderHandle(nint value) : this() => SetHandle(value);
|
||||
protected override bool ReleaseHandle() { NativeMethods.EncoderDestroy(handle); return true; }
|
||||
}
|
||||
|
||||
internal sealed class OpusDecoderHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public OpusDecoderHandle() : base(true) { }
|
||||
internal OpusDecoderHandle(nint value) : this() => SetHandle(value);
|
||||
protected override bool ReleaseHandle() { NativeMethods.DecoderDestroy(handle); return true; }
|
||||
}
|
||||
|
||||
internal sealed class DredDecoderHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public DredDecoderHandle() : base(true) { }
|
||||
internal DredDecoderHandle(nint value) : this() => SetHandle(value);
|
||||
protected override bool ReleaseHandle() { NativeMethods.DredDecoderDestroy(handle); return true; }
|
||||
}
|
||||
|
||||
internal sealed class DredHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public DredHandle() : base(true) { }
|
||||
internal DredHandle(nint value) : this() => SetHandle(value);
|
||||
protected override bool ReleaseHandle() { NativeMethods.DredDestroy(handle); return true; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
internal static unsafe partial class NativeMethods
|
||||
{
|
||||
private const string Library = "voicecat_media";
|
||||
[LibraryImport(Library, EntryPoint = "vcm_opus_version")]
|
||||
internal static partial nint Version();
|
||||
[LibraryImport(Library, EntryPoint = "vcm_opus_error")]
|
||||
internal static partial nint Error(int error);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_encoder_create")]
|
||||
internal static partial nint EncoderCreate(int rate, int channels, int application, out int error);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_encoder_destroy")]
|
||||
internal static partial void EncoderDestroy(nint encoder);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_encoder_set")]
|
||||
internal static partial int EncoderSet(OpusEncoderHandle encoder, int request, int value);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_encoder_get_dred")]
|
||||
internal static partial int EncoderGetDred(OpusEncoderHandle encoder, out int duration);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_encode")]
|
||||
internal static partial int Encode(OpusEncoderHandle encoder, short* pcm, int samples, byte* packet, int capacity);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_decoder_create")]
|
||||
internal static partial nint DecoderCreate(int rate, int channels, out int error);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_decoder_destroy")]
|
||||
internal static partial void DecoderDestroy(nint decoder);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_decode")]
|
||||
internal static partial int Decode(OpusDecoderHandle decoder, byte* packet, int length, short* pcm, int samples, int fec);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_dred_decoder_create")]
|
||||
internal static partial nint DredDecoderCreate(out int error);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_dred_decoder_destroy")]
|
||||
internal static partial void DredDecoderDestroy(nint decoder);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_dred_create")]
|
||||
internal static partial nint DredCreate(out int error);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_dred_destroy")]
|
||||
internal static partial void DredDestroy(nint dred);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_dred_parse")]
|
||||
internal static partial int DredParse(DredDecoderHandle decoder, DredHandle dred, byte* packet, int length, int samples, int rate, out int end);
|
||||
[LibraryImport(Library, EntryPoint = "vcm_dred_decode")]
|
||||
internal static partial int DredDecode(OpusDecoderHandle decoder, DredHandle dred, int offset, short* pcm, int samples);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
public sealed class OpusDecoder : IDisposable
|
||||
{
|
||||
private readonly OpusDecoderHandle handle;
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
|
||||
public OpusDecoder(int sampleRate = 48000, int channels = 1)
|
||||
{
|
||||
new OpusOptions { SampleRate = sampleRate, Channels = channels }.Validate();
|
||||
SampleRate = sampleRate;
|
||||
Channels = channels;
|
||||
handle = new(NativeMethods.DecoderCreate(sampleRate, channels, out int error));
|
||||
if (error < 0 || handle.IsInvalid)
|
||||
{
|
||||
handle.Dispose();
|
||||
OpusException.Check(error);
|
||||
throw new OutOfMemoryException();
|
||||
}
|
||||
}
|
||||
|
||||
internal OpusDecoderHandle Handle => handle;
|
||||
|
||||
internal void ValidateOutput(Span<short> pcm, int samplesPerChannel)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
||||
if (samplesPerChannel <= 0 || samplesPerChannel > SampleRate * 120 / 1000 || samplesPerChannel % (SampleRate / 400) != 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(samplesPerChannel));
|
||||
if (pcm.Length < samplesPerChannel * Channels) throw new ArgumentException("PCM storage is too small.", nameof(pcm));
|
||||
}
|
||||
|
||||
public unsafe int Decode(ReadOnlySpan<byte> packet, Span<short> pcm, int samplesPerChannel, bool recoverPreviousFrame = false)
|
||||
{
|
||||
ValidateOutput(pcm, samplesPerChannel);
|
||||
if (packet.Overlaps(MemoryMarshal.AsBytes(pcm))) throw new ArgumentException("Packet and PCM storage must not overlap.");
|
||||
fixed (byte* input = packet)
|
||||
fixed (short* output = pcm)
|
||||
return OpusException.Check(NativeMethods.Decode(handle, input, packet.Length, output, samplesPerChannel, recoverPreviousFrame ? 1 : 0));
|
||||
}
|
||||
|
||||
public void Dispose() => handle.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
public sealed class OpusDeepRedundancy : IDisposable
|
||||
{
|
||||
private readonly DredDecoderHandle decoder;
|
||||
private readonly DredHandle dred;
|
||||
|
||||
public OpusDeepRedundancy()
|
||||
{
|
||||
decoder = new(NativeMethods.DredDecoderCreate(out int error));
|
||||
if (error < 0 || decoder.IsInvalid)
|
||||
{
|
||||
decoder.Dispose();
|
||||
if (error == -5) throw new NotSupportedException("This libopus build does not include DRED.");
|
||||
OpusException.Check(error);
|
||||
throw new OutOfMemoryException();
|
||||
}
|
||||
dred = new(NativeMethods.DredCreate(out error));
|
||||
if (error < 0 || dred.IsInvalid)
|
||||
{
|
||||
decoder.Dispose();
|
||||
dred.Dispose();
|
||||
if (error == -5) throw new NotSupportedException("This libopus build does not include DRED.");
|
||||
OpusException.Check(error);
|
||||
throw new OutOfMemoryException();
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe bool TryRecover(OpusDecoder audioDecoder, ReadOnlySpan<byte> nextPacket, Span<short> pcm, int samplesPerChannel, int? offset = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(decoder.IsClosed, this);
|
||||
ArgumentNullException.ThrowIfNull(audioDecoder);
|
||||
audioDecoder.ValidateOutput(pcm, samplesPerChannel);
|
||||
int recoveryOffset = offset ?? samplesPerChannel;
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(recoveryOffset);
|
||||
if (nextPacket.IsEmpty) return false;
|
||||
if (nextPacket.Overlaps(MemoryMarshal.AsBytes(pcm))) throw new ArgumentException("Packet and PCM storage must not overlap.");
|
||||
fixed (byte* packet = nextPacket)
|
||||
fixed (short* output = pcm)
|
||||
{
|
||||
int parsed = OpusException.Check(NativeMethods.DredParse(decoder, dred, packet, nextPacket.Length,
|
||||
checked(samplesPerChannel + recoveryOffset), audioDecoder.SampleRate, out _));
|
||||
if (parsed == 0) return false;
|
||||
OpusException.Check(NativeMethods.DredDecode(audioDecoder.Handle, dred, recoveryOffset, output, samplesPerChannel));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() { dred.Dispose(); decoder.Dispose(); }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
public sealed class OpusEncoder : IDisposable
|
||||
{
|
||||
private readonly OpusEncoderHandle handle;
|
||||
public OpusOptions Options { get; }
|
||||
public bool SupportsDeepRedundancy { get; }
|
||||
public static string Version => Marshal.PtrToStringUTF8(NativeMethods.Version())!;
|
||||
|
||||
public OpusEncoder(OpusOptions? options = null)
|
||||
{
|
||||
Options = options ?? new();
|
||||
Options.Validate();
|
||||
handle = new(NativeMethods.EncoderCreate(Options.SampleRate, Options.Channels, (int)Options.Application, out int error));
|
||||
try
|
||||
{
|
||||
OpusException.Check(error);
|
||||
if (handle.IsInvalid) throw new OutOfMemoryException();
|
||||
Set(4002, Options.Bitrate);
|
||||
Set(4004, Options.MaximumBandwidthHz switch { 0 => 1105, <= 8000 => 1101, <= 12000 => 1102, <= 16000 => 1103, <= 24000 => 1104, _ => 1105 });
|
||||
Set(4010, Options.Complexity);
|
||||
Set(4012, Options.ForwardErrorCorrection ? 1 : 0);
|
||||
Set(4016, Options.DiscontinuousTransmission ? 1 : 0);
|
||||
Set(4014, Options.ExpectedPacketLossPercent);
|
||||
int support = NativeMethods.EncoderGetDred(handle, out _);
|
||||
if (support != -5) OpusException.Check(support);
|
||||
SupportsDeepRedundancy = support == 0 && Options.SampleRate >= 16000;
|
||||
if (Options.DeepRedundancy && !SupportsDeepRedundancy)
|
||||
throw new NotSupportedException("DRED encoding requires a DRED-enabled libopus build and a PCM rate of at least 16 kHz.");
|
||||
if (SupportsDeepRedundancy)
|
||||
// Opus 1.5.2 requires two redundancy chunks; 20 ms alone cannot produce DRED.
|
||||
Set(4050, Options.DeepRedundancy ? Math.Max(3, (Options.FrameDurationMilliseconds + 9) / 10) : 0);
|
||||
}
|
||||
catch { handle.Dispose(); throw; }
|
||||
}
|
||||
|
||||
private void Set(int request, int value) => OpusException.Check(NativeMethods.EncoderSet(handle, request, value));
|
||||
|
||||
public unsafe int Encode(ReadOnlySpan<short> pcm, Span<byte> packet)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
||||
if (pcm.Length != Options.SamplesPerChannel * Options.Channels) throw new ArgumentException("PCM must contain exactly one interleaved frame.", nameof(pcm));
|
||||
if (packet.IsEmpty) throw new ArgumentException("Packet storage must not be empty.", nameof(packet));
|
||||
if (MemoryMarshal.AsBytes(pcm).Overlaps(packet)) throw new ArgumentException("PCM and packet storage must not overlap.");
|
||||
fixed (short* input = pcm)
|
||||
fixed (byte* output = packet)
|
||||
return OpusException.Check(NativeMethods.Encode(handle, input, Options.SamplesPerChannel, output, packet.Length));
|
||||
}
|
||||
|
||||
public void Dispose() => handle.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
public sealed class OpusException : Exception
|
||||
{
|
||||
public int ErrorCode { get; }
|
||||
internal OpusException(int error) : base(Marshal.PtrToStringUTF8(NativeMethods.Error(error))) => ErrorCode = error;
|
||||
internal static int Check(int result) => result < 0 ? throw new OpusException(result) : result;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace VoiceCat.Codec;
|
||||
|
||||
public enum OpusApplication { Voip = 2048, Audio = 2049, LowDelay = 2051 }
|
||||
|
||||
public sealed record OpusOptions
|
||||
{
|
||||
public int SampleRate { get; init; } = 48000;
|
||||
public int Channels { get; init; } = 1;
|
||||
public int FrameDurationMilliseconds { get; init; } = 20;
|
||||
public int Bitrate { get; init; } = 24000;
|
||||
public int MaximumBandwidthHz { get; init; }
|
||||
public int Complexity { get; init; } = 10;
|
||||
public int ExpectedPacketLossPercent { get; init; }
|
||||
public bool ForwardErrorCorrection { get; init; } = true;
|
||||
public bool DiscontinuousTransmission { get; init; }
|
||||
public bool DeepRedundancy { get; init; }
|
||||
public OpusApplication Application { get; init; } = OpusApplication.Voip;
|
||||
public int SamplesPerChannel => SampleRate / 1000 * FrameDurationMilliseconds;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (SampleRate is not (8000 or 12000 or 16000 or 24000 or 48000)) throw new ArgumentOutOfRangeException(nameof(SampleRate));
|
||||
if (Channels is not (1 or 2)) throw new ArgumentOutOfRangeException(nameof(Channels));
|
||||
if (FrameDurationMilliseconds is not (10 or 20 or 40 or 60)) throw new ArgumentOutOfRangeException(nameof(FrameDurationMilliseconds));
|
||||
if (Application == OpusApplication.LowDelay && FrameDurationMilliseconds > 20) throw new ArgumentException("Low-delay Opus requires frames of at most 20 ms.");
|
||||
if (!Enum.IsDefined(Application)) throw new ArgumentOutOfRangeException(nameof(Application));
|
||||
if (Bitrate is < 500 or > 512000) throw new ArgumentOutOfRangeException(nameof(Bitrate));
|
||||
if (Complexity is < 0 or > 10) throw new ArgumentOutOfRangeException(nameof(Complexity));
|
||||
if (ExpectedPacketLossPercent is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(ExpectedPacketLossPercent));
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(MaximumBandwidthHz);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
|
||||
namespace VoiceCat.Crypto;
|
||||
|
||||
public sealed class PasswordHasher
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8 = new(false, true);
|
||||
public const int MaximumPasswordBytes = 1024;
|
||||
|
||||
public string Hash(string password)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||
byte[] hash = Derive(password, salt, 65536, 2, 1);
|
||||
try { return $"$argon2id$v=19$m=65536,t=2,p=1${Base64(salt)}${Base64(hash)}"; }
|
||||
finally { CryptographicOperations.ZeroMemory(hash); }
|
||||
}
|
||||
|
||||
public bool Verify(string password, string encodedHash)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(password);
|
||||
ArgumentNullException.ThrowIfNull(encodedHash);
|
||||
if (encodedHash.Length > 256) return false;
|
||||
try { if (Utf8.GetByteCount(password) > MaximumPasswordBytes) return false; }
|
||||
catch (EncoderFallbackException) { return false; }
|
||||
string[] fields = encodedHash.Split('$');
|
||||
if (fields.Length != 6 || fields[0] != "" || fields[1] != "argon2id" || fields[2] != "v=19") return false;
|
||||
string[] costs = fields[3].Split(',');
|
||||
if (costs.Length != 3 || !Cost(costs[0], "m=", out int memory) || !Cost(costs[1], "t=", out int iterations) || !Cost(costs[2], "p=", out int parallelism)) return false;
|
||||
if (memory is < 8 or > 131072 || iterations is < 1 or > 10 || parallelism is < 1 or > 4 || memory < 8 * parallelism) return false;
|
||||
byte[] salt, expected;
|
||||
try { salt = Decode(fields[4]); expected = Decode(fields[5]); }
|
||||
catch (FormatException) { return false; }
|
||||
if (salt.Length != 16 || expected.Length != 32) return false;
|
||||
byte[] actual = Derive(password, salt, memory, iterations, parallelism);
|
||||
try { return CryptographicOperations.FixedTimeEquals(actual, expected); }
|
||||
finally { CryptographicOperations.ZeroMemory(actual); }
|
||||
}
|
||||
|
||||
private static bool Cost(string value, string prefix, out int cost)
|
||||
{
|
||||
cost = 0;
|
||||
return value.StartsWith(prefix, StringComparison.Ordinal) && int.TryParse(value.AsSpan(prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out cost);
|
||||
}
|
||||
|
||||
private static byte[] Derive(string password, byte[] salt, int memory, int iterations, int parallelism)
|
||||
{
|
||||
if (Utf8.GetByteCount(password) > MaximumPasswordBytes) throw new ArgumentException("Password exceeds 1024 UTF-8 bytes.", nameof(password));
|
||||
byte[] bytes = Utf8.GetBytes(password);
|
||||
byte[] output = new byte[32];
|
||||
var parameters = new Argon2Parameters.Builder(Argon2Parameters.Argon2id)
|
||||
.WithVersion(Argon2Parameters.Version13).WithMemoryAsKB(memory)
|
||||
.WithIterations(iterations).WithParallelism(parallelism).WithSalt(salt).Build();
|
||||
try
|
||||
{
|
||||
var generator = new Argon2BytesGenerator();
|
||||
generator.Init(parameters);
|
||||
generator.GenerateBytes(bytes, output);
|
||||
return output;
|
||||
}
|
||||
catch { CryptographicOperations.ZeroMemory(output); throw; }
|
||||
finally { CryptographicOperations.ZeroMemory(bytes); }
|
||||
}
|
||||
|
||||
private static string Base64(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=');
|
||||
private static byte[] Decode(string value)
|
||||
{
|
||||
if (value.Contains('=') || value.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not ('+' or '/'))) throw new FormatException();
|
||||
byte[] bytes = Convert.FromBase64String(value.PadRight((value.Length + 3) / 4 * 4, '='));
|
||||
if (Base64(bytes) != value) throw new FormatException();
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace VoiceCat.Dsp;
|
||||
|
||||
public sealed class EnergyVadProcessor
|
||||
{
|
||||
private readonly TimeProvider timeProvider;
|
||||
private long lastVoiceTimestamp;
|
||||
private bool hasVoice;
|
||||
private float threshold;
|
||||
|
||||
public float Threshold
|
||||
{
|
||||
get => Volatile.Read(ref threshold);
|
||||
set
|
||||
{
|
||||
if (!float.IsFinite(value) || value is < 0 or > 1) throw new ArgumentOutOfRangeException(nameof(value));
|
||||
Volatile.Write(ref threshold, value);
|
||||
}
|
||||
}
|
||||
public TimeSpan HangTime { get; }
|
||||
|
||||
public EnergyVadProcessor(float threshold = 0.02f, TimeSpan? hangTime = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
Threshold = threshold;
|
||||
HangTime = hangTime ?? TimeSpan.FromMilliseconds(300);
|
||||
if (HangTime < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(hangTime));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public bool Process(ReadOnlySpan<short> pcm)
|
||||
{
|
||||
long now = timeProvider.GetTimestamp();
|
||||
if (!pcm.IsEmpty)
|
||||
{
|
||||
double sum = 0;
|
||||
foreach (short sample in pcm)
|
||||
{
|
||||
double normalized = sample / 32768.0;
|
||||
sum += normalized * normalized;
|
||||
}
|
||||
if (Math.Sqrt(sum / pcm.Length) >= Threshold)
|
||||
{
|
||||
lastVoiceTimestamp = now;
|
||||
hasVoice = true;
|
||||
}
|
||||
}
|
||||
return hasVoice && timeProvider.GetElapsedTime(lastVoiceTimestamp, now) < HangTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace VoiceCat.Dsp;
|
||||
|
||||
public sealed unsafe partial class RnnoiseProcessor : IDisposable
|
||||
{
|
||||
public const int SampleRate = 48000;
|
||||
public const int FrameSamples = 480;
|
||||
private readonly RnnoiseHandle handle;
|
||||
private readonly float[] input = new float[FrameSamples];
|
||||
private readonly float[] output = new float[FrameSamples];
|
||||
|
||||
public RnnoiseProcessor()
|
||||
{
|
||||
handle = new(Create());
|
||||
if (handle.IsInvalid) { handle.Dispose(); throw new OutOfMemoryException(); }
|
||||
}
|
||||
|
||||
public void Process(Span<short> pcm, int sampleRate = SampleRate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(handle.IsClosed, this);
|
||||
if (sampleRate != SampleRate) return;
|
||||
if (pcm.Length % FrameSamples != 0) throw new ArgumentException("RNNoise requires complete 480-sample mono chunks.", nameof(pcm));
|
||||
fixed (float* source = input)
|
||||
fixed (float* destination = output)
|
||||
{
|
||||
for (int offset = 0; offset < pcm.Length; offset += FrameSamples)
|
||||
{
|
||||
for (int i = 0; i < FrameSamples; i++) input[i] = pcm[offset + i];
|
||||
ProcessFrame(handle, destination, source);
|
||||
for (int i = 0; i < FrameSamples; i++)
|
||||
pcm[offset + i] = (short)Math.Clamp(MathF.Round(output[i], MidpointRounding.AwayFromZero), short.MinValue, short.MaxValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => handle.Dispose();
|
||||
|
||||
[LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_create")]
|
||||
private static partial nint Create();
|
||||
[LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_destroy")]
|
||||
private static partial void Destroy(nint state);
|
||||
[LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_process")]
|
||||
private static partial float ProcessFrame(RnnoiseHandle state, float* output, float* input);
|
||||
|
||||
private sealed class RnnoiseHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public RnnoiseHandle() : base(true) { }
|
||||
internal RnnoiseHandle(nint value) : this() => SetHandle(value);
|
||||
protected override bool ReleaseHandle() { Destroy(handle); return true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
namespace VoiceCat.Server.Data;
|
||||
|
||||
public sealed record Account(long Id, string Username, bool IsAdmin, long CreatedAt, long LastLogin);
|
||||
|
||||
public sealed class AccountStore : IDisposable
|
||||
{
|
||||
static AccountStore() => SQLitePCL.Batteries_V2.Init();
|
||||
private readonly string connectionString;
|
||||
private readonly PasswordHasher hasher = new();
|
||||
private readonly SemaphoreSlim passwordWorkers = new(2);
|
||||
private bool disposed;
|
||||
private const string DummyHash = "$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE";
|
||||
|
||||
public AccountStore(string path)
|
||||
{
|
||||
connectionString = new SqliteConnectionStringBuilder { DataSource = Path.GetFullPath(path), Pooling = false, DefaultTimeout = 5 }.ToString();
|
||||
using var connection = Open();
|
||||
using var setup = connection.CreateCommand();
|
||||
setup.CommandText = "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);";
|
||||
setup.ExecuteNonQuery();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
using var version = connection.CreateCommand();
|
||||
version.Transaction = transaction;
|
||||
version.CommandText = "SELECT value FROM server_meta WHERE key='schema_version'";
|
||||
object? stored = version.ExecuteScalar();
|
||||
if (stored is not null && (!int.TryParse((string)stored, NumberStyles.None, CultureInfo.InvariantCulture, out int revision) || revision is < 1 or > 2))
|
||||
throw new InvalidDataException("Unsupported server database schema version.");
|
||||
using var resource = typeof(AccountStore).Assembly.GetManifestResourceStream("VoiceCat.Server.Data.schema.sql")!;
|
||||
using var reader = new StreamReader(resource);
|
||||
using var migrate = connection.CreateCommand();
|
||||
migrate.Transaction = transaction;
|
||||
migrate.CommandText = reader.ReadToEnd() + "INSERT INTO server_meta (key,value) VALUES ('schema_version','2') ON CONFLICT(key) DO UPDATE SET value='2';";
|
||||
migrate.ExecuteNonQuery();
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
var connection = new SqliteConnection(connectionString);
|
||||
try { connection.Open(); return connection; }
|
||||
catch { connection.Dispose(); throw; }
|
||||
}
|
||||
|
||||
public async Task<Account> CreateAccountAsync(string username, string password, bool isAdmin = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(username);
|
||||
if (username.Length > 128) throw new ArgumentException("Username exceeds 128 characters.", nameof(username));
|
||||
string hash = await PasswordWorkAsync(() => hasher.Hash(password), cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
long created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
command.CommandText = "INSERT INTO accounts (username,pw_hash,is_admin,created_at) VALUES ($user,$hash,$admin,$created) RETURNING id";
|
||||
command.Parameters.AddWithValue("$user", username);
|
||||
command.Parameters.AddWithValue("$hash", hash);
|
||||
command.Parameters.AddWithValue("$admin", isAdmin ? 1 : 0);
|
||||
command.Parameters.AddWithValue("$created", created);
|
||||
return new((long)command.ExecuteScalar()!, username, isAdmin, created, 0);
|
||||
}
|
||||
|
||||
public async Task<Account?> AuthenticateAsync(string username, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? hash = null;
|
||||
Account? account = null;
|
||||
using (var connection = Open())
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "SELECT id,pw_hash,is_admin,created_at,last_login FROM accounts WHERE username=$user";
|
||||
command.Parameters.AddWithValue("$user", username);
|
||||
using var reader = command.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
hash = reader.GetString(1);
|
||||
account = new(reader.GetInt64(0), username, reader.GetInt64(2) != 0, reader.GetInt64(3), reader.GetInt64(4));
|
||||
}
|
||||
}
|
||||
bool verified = await PasswordWorkAsync(() => hasher.Verify(password, hash ?? DummyHash), cancellationToken).ConfigureAwait(false);
|
||||
if (hash is null || !verified) return null;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var updated = Open();
|
||||
using var update = updated.CreateCommand();
|
||||
long login = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
update.CommandText = "UPDATE accounts SET last_login=$login WHERE id=$id AND pw_hash=$hash";
|
||||
update.Parameters.AddWithValue("$login", login);
|
||||
update.Parameters.AddWithValue("$id", account!.Id);
|
||||
update.Parameters.AddWithValue("$hash", hash);
|
||||
return update.ExecuteNonQuery() == 1 ? account with { LastLogin = login } : null;
|
||||
}
|
||||
|
||||
private async Task<T> PasswordWorkAsync<T>(Func<T> work, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
await passwordWorkers.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try { return await Task.Run(work, cancellationToken).ConfigureAwait(false); }
|
||||
finally { passwordWorkers.Release(); }
|
||||
}
|
||||
|
||||
public void Dispose() => disposed = true;
|
||||
|
||||
public IReadOnlyList<Voicecat.V1.Channel> LoadChannels()
|
||||
{
|
||||
using var connection = Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
using var seed = connection.CreateCommand();
|
||||
seed.Transaction = transaction;
|
||||
seed.CommandText = "SELECT COUNT(*) FROM channels";
|
||||
bool empty = (long)seed.ExecuteScalar()! == 0;
|
||||
seed.CommandText = """
|
||||
INSERT INTO channels (id,name,max_users) VALUES (1,'Lobby',20);
|
||||
INSERT INTO channels (id,name,audio_mode,audio_bitrate_bps,audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity,sort_order)
|
||||
VALUES (2,'Music Room',1,128000,1,0,0,0,8,1);
|
||||
""";
|
||||
if (empty) seed.ExecuteNonQuery();
|
||||
transaction.Commit();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT id,parent_id,name,topic,password_hash,max_users,type,sort_order,
|
||||
audio_codec,audio_mode,audio_sample_rate,audio_bitrate_bps,audio_frame_ms,
|
||||
audio_application,audio_fec,audio_expected_packet_loss,audio_dtx,audio_complexity
|
||||
FROM channels ORDER BY sort_order,id
|
||||
""";
|
||||
using var reader = command.ExecuteReader();
|
||||
var channels = new List<Voicecat.V1.Channel>();
|
||||
while (reader.Read())
|
||||
{
|
||||
channels.Add(new()
|
||||
{
|
||||
Id = checked((uint)reader.GetInt64(0)), ParentId = checked((uint)reader.GetInt64(1)),
|
||||
Name = reader.GetString(2), Topic = reader.GetString(3), PasswordProtected = reader.GetString(4).Length != 0,
|
||||
MaxUsers = checked((uint)reader.GetInt64(5)), Type = (Voicecat.V1.ChannelType)reader.GetInt32(6), Order = reader.GetInt32(7),
|
||||
Audio = new()
|
||||
{
|
||||
Codec = checked((uint)reader.GetInt64(8)), Mode = (Voicecat.V1.ChannelMode)reader.GetInt32(9),
|
||||
SampleRate = checked((uint)reader.GetInt64(10)), BitrateBps = checked((uint)reader.GetInt64(11)),
|
||||
FrameMs = checked((uint)reader.GetInt64(12)), Application = (Voicecat.V1.OpusApplication)reader.GetInt32(13),
|
||||
Fec = reader.GetInt32(14) != 0, ExpectedPacketLoss = checked((uint)reader.GetInt64(15)),
|
||||
Dtx = reader.GetInt32(16) != 0, Complexity = checked((uint)reader.GetInt64(17))
|
||||
}
|
||||
});
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
public bool IsBanned(string subjectType, string subject)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT 1 FROM bans WHERE subject_type=$type AND subject=$subject AND (expires_at=0 OR expires_at>$now) LIMIT 1";
|
||||
command.Parameters.AddWithValue("$type", subjectType);
|
||||
command.Parameters.AddWithValue("$subject", subject);
|
||||
command.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
return command.ExecuteScalar() is not null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
pw_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_login INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_id INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
topic TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
max_users INTEGER NOT NULL DEFAULT 0,
|
||||
type INTEGER NOT NULL DEFAULT 0,
|
||||
audio_codec INTEGER NOT NULL DEFAULT 0,
|
||||
audio_mode INTEGER NOT NULL DEFAULT 0,
|
||||
audio_sample_rate INTEGER NOT NULL DEFAULT 48000,
|
||||
audio_bitrate_bps INTEGER NOT NULL DEFAULT 24000,
|
||||
audio_frame_ms INTEGER NOT NULL DEFAULT 20,
|
||||
audio_application INTEGER NOT NULL DEFAULT 0,
|
||||
audio_fec INTEGER NOT NULL DEFAULT 1,
|
||||
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
|
||||
audio_dtx INTEGER NOT NULL DEFAULT 1,
|
||||
audio_complexity INTEGER NOT NULL DEFAULT 5,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subject_type TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bans_subject ON bans(subject_type, subject);
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Net;
|
||||
using VoiceCat.Server;
|
||||
|
||||
string directory = args.Length > 0 ? args[0] : "voicecat-data";
|
||||
int port = args.Length > 1 ? int.Parse(args[1], System.Globalization.CultureInfo.InvariantCulture) : 7443;
|
||||
using var stop = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; stop.Cancel(); };
|
||||
await using var server = new VoiceServer(directory, new IPEndPoint(IPAddress.Loopback, port));
|
||||
server.ConnectionFailed += exception => Console.Error.WriteLine($"Connection closed: {exception.Message}");
|
||||
Console.WriteLine($"VoiceCat managed control server listening on {server.EndPoint}");
|
||||
try { await Task.Delay(Timeout.Infinite, stop.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Server.Transport;
|
||||
|
||||
internal sealed class TlsControlConnection : IAsyncDisposable
|
||||
{
|
||||
internal const int MaximumPayloadLength = 65536;
|
||||
private readonly Socket socket;
|
||||
private readonly TlsSession tls;
|
||||
private readonly CancellationTokenSource lifetime;
|
||||
private readonly Channel<byte[]> outgoing = System.Threading.Channels.Channel.CreateBounded<byte[]>(64);
|
||||
private readonly Channel<Envelope> incoming = System.Threading.Channels.Channel.CreateBounded<Envelope>(32);
|
||||
private readonly byte[] prefix = new byte[4];
|
||||
private int prefixBytes;
|
||||
private byte[]? payload;
|
||||
private int payloadBytes;
|
||||
|
||||
public Task Completion { get; }
|
||||
public CancellationToken CancellationToken => lifetime.Token;
|
||||
|
||||
internal TlsControlConnection(Socket socket, TlsSession tls, CancellationToken cancellationToken)
|
||||
{
|
||||
this.socket = socket;
|
||||
this.tls = tls;
|
||||
lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
lifetime.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
Completion = RunAsync();
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Envelope> ReadAsync(CancellationToken cancellationToken) => incoming.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public bool TrySend(Envelope envelope)
|
||||
{
|
||||
if (envelope.CalculateSize() > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB.");
|
||||
var framed = new ArrayBufferWriter<byte>();
|
||||
ControlFraming.WriteEnvelope(framed, envelope);
|
||||
if (outgoing.Writer.TryWrite(framed.WrittenSpan.ToArray())) return true;
|
||||
lifetime.Cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
public void CompleteWrites() => outgoing.Writer.TryComplete();
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
byte[] ciphertext = new byte[16384];
|
||||
byte[] plaintext = new byte[16384];
|
||||
byte[] sendBuffer = new byte[16384];
|
||||
CancellationToken cancellationToken = lifetime.Token;
|
||||
Task<int>? receive = null;
|
||||
Task<bool>? ready = null;
|
||||
Exception? error = null;
|
||||
try
|
||||
{
|
||||
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
|
||||
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
|
||||
while (true)
|
||||
{
|
||||
if (tls.IsReady)
|
||||
{
|
||||
while (outgoing.Reader.TryRead(out byte[]? frame)) tls.WritePlaintext(frame);
|
||||
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
|
||||
ready ??= outgoing.Reader.WaitToReadAsync(cancellationToken).AsTask();
|
||||
}
|
||||
Task winner = ready is null ? receive : await Task.WhenAny(receive, ready).ConfigureAwait(false);
|
||||
if (winner == receive)
|
||||
{
|
||||
int count = await receive.ConfigureAwait(false);
|
||||
if (count == 0)
|
||||
{
|
||||
tls.CompleteInput();
|
||||
if (prefixBytes != 0 || payload is not null) throw new InvalidDataException("Truncated control frame.");
|
||||
break;
|
||||
}
|
||||
tls.ReceiveCiphertext(ciphertext.AsSpan(0, count));
|
||||
if (tls.IsReady) lifetime.CancelAfter(TimeSpan.FromSeconds(60));
|
||||
while ((count = tls.ReadPlaintext(plaintext)) > 0) Parse(plaintext.AsSpan(0, count));
|
||||
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
|
||||
receive = socket.ReceiveAsync(ciphertext, SocketFlags.None, cancellationToken).AsTask();
|
||||
}
|
||||
else
|
||||
{
|
||||
bool hasOutgoing = await ready!.ConfigureAwait(false);
|
||||
ready = null;
|
||||
if (!hasOutgoing)
|
||||
{
|
||||
tls.Close();
|
||||
await FlushAsync(sendBuffer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested) error = exception;
|
||||
}
|
||||
finally
|
||||
{
|
||||
lifetime.Cancel();
|
||||
socket.Dispose();
|
||||
if (receive is not null)
|
||||
{
|
||||
try { await receive.ConfigureAwait(false); }
|
||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException or ObjectDisposedException) { }
|
||||
}
|
||||
tls.Dispose();
|
||||
incoming.Writer.TryComplete(error);
|
||||
outgoing.Writer.TryComplete(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FlushAsync(byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
int count;
|
||||
while ((count = tls.DrainCiphertext(buffer)) > 0)
|
||||
{
|
||||
int sent = 0;
|
||||
while (sent < count)
|
||||
{
|
||||
int written = await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, cancellationToken).ConfigureAwait(false);
|
||||
if (written == 0) throw new IOException("Socket closed during TLS send.");
|
||||
sent += written;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Parse(ReadOnlySpan<byte> input)
|
||||
{
|
||||
while (!input.IsEmpty)
|
||||
{
|
||||
if (payload is null)
|
||||
{
|
||||
int count = Math.Min(4 - prefixBytes, input.Length);
|
||||
input[..count].CopyTo(prefix.AsSpan(prefixBytes));
|
||||
prefixBytes += count;
|
||||
input = input[count..];
|
||||
if (prefixBytes != 4) continue;
|
||||
uint length = BinaryPrimitives.ReadUInt32BigEndian(prefix);
|
||||
if (length > MaximumPayloadLength) throw new InvalidDataException("Server control payload exceeds 64 KiB.");
|
||||
payload = new byte[length];
|
||||
prefixBytes = 0;
|
||||
}
|
||||
int consumed = Math.Min(payload.Length - payloadBytes, input.Length);
|
||||
input[..consumed].CopyTo(payload.AsSpan(payloadBytes));
|
||||
payloadBytes += consumed;
|
||||
input = input[consumed..];
|
||||
if (payloadBytes != payload.Length) continue;
|
||||
Envelope envelope = Envelope.Parser.ParseFrom(payload);
|
||||
payload = null;
|
||||
payloadBytes = 0;
|
||||
if (!incoming.Writer.TryWrite(envelope)) throw new IOException("Control consumer exceeded its bounded queue.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
lifetime.Cancel();
|
||||
await Completion.ConfigureAwait(false);
|
||||
lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="10.0.5" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.2" />
|
||||
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.2" />
|
||||
<EmbeddedResource Include="Data/schema.sql" />
|
||||
<InternalsVisibleTo Include="VoiceCat.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Server.Data;
|
||||
using VoiceCat.Server.Transport;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Server;
|
||||
|
||||
public sealed class VoiceServer : IAsyncDisposable
|
||||
{
|
||||
private readonly Socket listener;
|
||||
private readonly ServerCredentials credentials;
|
||||
private readonly AccountStore accounts;
|
||||
private readonly IReadOnlyList<Voicecat.V1.Channel> channels;
|
||||
private readonly bool allowGuests;
|
||||
private readonly string name;
|
||||
private readonly CancellationTokenSource shutdown = new();
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<ulong, Session> sessions = [];
|
||||
private readonly List<Task> connections = [];
|
||||
private ulong nextSession;
|
||||
private uint nextUser;
|
||||
private readonly Task accepting;
|
||||
private int disposed;
|
||||
|
||||
public IPEndPoint EndPoint => (IPEndPoint)listener.LocalEndPoint!;
|
||||
public event Action<Exception>? ConnectionFailed;
|
||||
|
||||
public VoiceServer(string directory, IPEndPoint endpoint, bool allowGuests = true, string name = "VoiceCat Server")
|
||||
{
|
||||
this.allowGuests = allowGuests;
|
||||
this.name = name;
|
||||
credentials = ServerCredentials.LoadOrCreate(directory, name);
|
||||
try
|
||||
{
|
||||
accounts = new AccountStore(Path.Combine(directory, "voicecat.db"));
|
||||
channels = accounts.LoadChannels();
|
||||
listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
listener.Bind(endpoint);
|
||||
listener.Listen(64);
|
||||
}
|
||||
catch
|
||||
{
|
||||
listener?.Dispose();
|
||||
accounts?.Dispose();
|
||||
credentials.Dispose();
|
||||
shutdown.Dispose();
|
||||
throw;
|
||||
}
|
||||
accepting = AcceptAsync();
|
||||
}
|
||||
|
||||
private async Task AcceptAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!shutdown.IsCancellationRequested)
|
||||
{
|
||||
Socket socket = await listener.AcceptAsync(shutdown.Token).ConfigureAwait(false);
|
||||
lock (gate)
|
||||
{
|
||||
if (sessions.Count >= 64) { socket.Dispose(); continue; }
|
||||
socket.NoDelay = true;
|
||||
string address = ((IPEndPoint)socket.RemoteEndPoint!).Address.ToString();
|
||||
var connection = new TlsControlConnection(socket, credentials.CreateTlsSession(), shutdown.Token);
|
||||
var session = new Session(++nextSession, connection, address);
|
||||
sessions.Add(session.Id, session);
|
||||
connections.RemoveAll(task => task.IsCompleted);
|
||||
connections.Add(HandleAsync(session));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (shutdown.IsCancellationRequested && exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
private async Task HandleAsync(Session session)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (envelope.Ping is not null)
|
||||
{
|
||||
session.Connection.TrySend(new() { RequestId = envelope.RequestId, Pong = new() { Nonce = envelope.Ping.Nonce } });
|
||||
continue;
|
||||
}
|
||||
if (envelope.Disconnect is not null) { session.Connection.CompleteWrites(); break; }
|
||||
if (!session.HelloReceived)
|
||||
{
|
||||
if (envelope.ClientHello?.ProtoVersion != 2 || accounts.IsBanned("ip", session.Address))
|
||||
{
|
||||
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)) };
|
||||
if (allowGuests) hello.AuthMethods.Add("guest");
|
||||
hello.AuthMethods.Add("password");
|
||||
session.Connection.TrySend(new() { RequestId = envelope.RequestId, ServerHello = hello });
|
||||
session.HelloReceived = true;
|
||||
continue;
|
||||
}
|
||||
if (session.User is null)
|
||||
{
|
||||
if (envelope.AuthRequest is null) { Reject(session, "Authentication required."); break; }
|
||||
await AuthenticateAsync(session, envelope.RequestId, envelope.AuthRequest).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
switch (envelope.BodyCase)
|
||||
{
|
||||
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." } });
|
||||
break;
|
||||
default:
|
||||
session.Connection.TrySend(new() { RequestId = envelope.RequestId, GenericResult = new() { Code = 1, Message = "Operation is not implemented by this server checkpoint." } });
|
||||
break;
|
||||
}
|
||||
}
|
||||
await session.Connection.Completion.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
if (!shutdown.IsCancellationRequested && exception is not OperationCanceledException) ConnectionFailed?.Invoke(exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
sessions.Remove(session.Id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Reject(Session session, string reason)
|
||||
{
|
||||
session.Connection.TrySend(new() { Disconnect = new() { Code = 1, Reason = reason } });
|
||||
session.Connection.CompleteWrites();
|
||||
}
|
||||
|
||||
private async Task AuthenticateAsync(Session session, ulong requestId, AuthRequest request)
|
||||
{
|
||||
User? user = null;
|
||||
bool admin = false;
|
||||
if (request.Guest is not null && allowGuests && request.Guest.Nickname.Length <= 128)
|
||||
user = new() { Nickname = request.Guest.Nickname.Length == 0 ? "Guest" : request.Guest.Nickname, IsGuest = true, ChannelId = 1 };
|
||||
else if (request.Password is not null && request.Password.Username.Length <= 128 && request.Password.Password.Length <= 1024 && !accounts.IsBanned("username", request.Password.Username))
|
||||
{
|
||||
Account? account = await accounts.AuthenticateAsync(request.Password.Username, request.Password.Password, session.Connection.CancellationToken).ConfigureAwait(false);
|
||||
if (account is not null) { user = new() { Nickname = account.Username, ChannelId = 1 }; admin = account.IsAdmin; }
|
||||
}
|
||||
shutdown.Token.ThrowIfCancellationRequested();
|
||||
session.Connection.CancellationToken.ThrowIfCancellationRequested();
|
||||
lock (gate)
|
||||
{
|
||||
var lobby = channels.FirstOrDefault(channel => channel.Id == 1);
|
||||
if (user is null || lobby is null || lobby.PasswordProtected || lobby.MaxUsers != 0 && sessions.Values.Count(peer => peer.User?.ChannelId == 1) >= lobby.MaxUsers)
|
||||
{
|
||||
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new() { Error = "Invalid credentials or lobby unavailable." } });
|
||||
return;
|
||||
}
|
||||
user.Id = checked(++nextUser);
|
||||
session.User = user;
|
||||
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new()
|
||||
{
|
||||
Ok = true, SessionId = session.Id, Self = user.Clone(),
|
||||
Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin }
|
||||
} });
|
||||
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id);
|
||||
SendSnapshot(session);
|
||||
}
|
||||
}
|
||||
|
||||
private void SendSnapshot(Session session)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var snapshot = new ServerStateSnapshot();
|
||||
snapshot.Channels.Add(channels.Select(channel => channel.Clone()));
|
||||
snapshot.Users.Add(sessions.Values.Where(peer => peer.User is not null).Select(peer => peer.User!.Clone()));
|
||||
session.Connection.TrySend(new() { ServerState = snapshot });
|
||||
}
|
||||
}
|
||||
|
||||
private void Join(Session session, ulong requestId, uint channelId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var channel = channels.FirstOrDefault(candidate => candidate.Id == channelId);
|
||||
if (channel is null || channel.PasswordProtected || channel.MaxUsers != 0 && sessions.Values.Count(peer => peer.Id != session.Id && peer.User?.ChannelId == channelId) >= channel.MaxUsers)
|
||||
{
|
||||
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = new() { Error = "Channel unavailable." } });
|
||||
return;
|
||||
}
|
||||
session.User!.ChannelId = channelId;
|
||||
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 });
|
||||
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Updated, User = session.User.Clone() } });
|
||||
}
|
||||
}
|
||||
|
||||
private void RelayText(Session sender, TextMessage message)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
bool permitted = Encoding.UTF8.GetByteCount(message.Body) <= 4096 && message.ClientMsgId.Length <= 128 &&
|
||||
(message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && message.TargetId == sender.User!.ChannelId ||
|
||||
message.Scope == TextScope.TextPrivate && sessions.Values.Any(peer => peer.User?.Id == message.TargetId));
|
||||
if (permitted)
|
||||
{
|
||||
var relay = message.Clone();
|
||||
relay.SenderId = sender.User!.Id;
|
||||
relay.SentAtUnixMs = checked((ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
var envelope = new Envelope { TextMessage = relay };
|
||||
foreach (Session recipient in sessions.Values.Where(peer => peer.User is not null))
|
||||
if (message.Scope == TextScope.TextServer || message.Scope == TextScope.TextChannel && recipient.User!.ChannelId == message.TargetId ||
|
||||
message.Scope == TextScope.TextPrivate && (recipient.User!.Id == message.TargetId || recipient.Id == sender.Id))
|
||||
recipient.Connection.TrySend(envelope);
|
||||
}
|
||||
sender.Connection.TrySend(new() { TextMessageAck = new() { ClientMsgId = message.ClientMsgId, Ok = permitted } });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
|
||||
shutdown.Cancel();
|
||||
listener.Dispose();
|
||||
try
|
||||
{
|
||||
await accepting.ConfigureAwait(false);
|
||||
Task[] pending;
|
||||
lock (gate) pending = connections.ToArray();
|
||||
await Task.WhenAll(pending).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
accounts.Dispose();
|
||||
credentials.Dispose();
|
||||
shutdown.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Session(ulong id, TlsControlConnection connection, string address)
|
||||
{
|
||||
public ulong Id { get; } = id;
|
||||
public TlsControlConnection Connection { get; } = connection;
|
||||
public string Address { get; } = address;
|
||||
public bool HelloReceived { get; set; }
|
||||
public User? User { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"SourceGear.sqlite3": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.50.4.2, )",
|
||||
"resolved": "3.50.4.2",
|
||||
"contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g=="
|
||||
},
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.2, )",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.config.e_sqlite3": "3.0.2",
|
||||
"SourceGear.sqlite3": "3.50.4.2"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"SQLitePCLRaw.config.e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.provider.e_sqlite3": "3.0.2"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ=="
|
||||
},
|
||||
"SQLitePCLRaw.provider.e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.0.2",
|
||||
"contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "3.0.2"
|
||||
}
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "[2.6.2, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user