Add managed codec DSP and initial control server

This commit is contained in:
2026-09-15 22:51:33 +02:00
parent 2df79cdd4c
commit 4067bab7c2
52 changed files with 2503 additions and 20 deletions
@@ -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);
}
+45
View File
@@ -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(); }
}
+53
View File
@@ -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;
}
+32
View File
@@ -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);
+12
View File
@@ -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>
+265
View File
@@ -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, )"
}
}
}
}
}