diff --git a/PROGRESS.md b/PROGRESS.md index b6a047c..5871955 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,29 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done (2026-09-15): Managed channel and administration checkpoint.** Reaper committed + as `274b850`. Added protected joins and capacity checks, leave-to-Lobby, persisted channel + create/edit/delete with events, parent/cycle validation and Lobby protection. Native + salted BLAKE2b channel hashes work in both directions; empty edit passwords preserve + protection. Channel edits, moves and deletion clear streams before further media routing. + Session permissions gate kick/ban/move/server-mute/deafen and account create/reset/delete/ + list. Only administrators grant permissions; temporary-channel permission cannot create + permanent channels. Kick/ban retire media and send one reason-bearing LEFT. Account bans + persist by username, guest bans by address; Unix-millisecond expiry converts to database + seconds, fixing the native handler's unit mismatch. Bounded Argon2 work remains outside + the session lock; account lists exclude hashes and respect the frame limit. The existing + C++ CLI successfully creates protected channels and creates/lists accounts against the + managed server. Fixed its temporary channel-string pointers and zero audio defaults. + A native sample-rate regression intermittently measured host microphone audio alongside + its injected tone; changed that test to external capture/playback and kept callback state + alive through client shutdown, with synchronized energy reads. + **Verified:** 169/169 managed tests with all native conformance enabled, zero skips; + warning-free managed Release build, native dev build and 29/29 CTest tests; diff check. + **Next:** production configuration, administrator provisioning/publishing and remaining + server readiness checks (including auth rate limiting). Phase 4 is still in progress. + Then managed audio/core/CLI, Windows cutover, C# AppKit and UIKit clients; preserve Swift + ReplayKit extension and freeze the shared-ring contract before the iOS cutover. + - **Done (2026-09-15): Managed media-aware reaper.** Voice checkpoint committed as `05eacb3`. Added `VoiceServerOptions` (name/guests/capacity, handshake deadline, idle timeout and sweep interval), preserving the previous constructor overload. diff --git a/docs/api-dotnet.md b/docs/api-dotnet.md index 008afc5..5ff3862 100644 --- a/docs/api-dotnet.md +++ b/docs/api-dotnet.md @@ -194,8 +194,38 @@ Authentication starts users in unprotected Lobby (id 1), subject to its capacity Success returns permissions, then a cloned snapshot; peers receive joined/updated/left events. Server-authoritative text replaces supplied sender ids/timestamps, limits bodies to 4096 UTF-8 bytes, and acknowledges valid or rejected routing. Channel text -requires membership; private text echoes to sender and recipient. Protected channel -joins and all admin/moderation handlers are pending. +requires membership; private text echoes to sender and recipient. Protected joins enforce +the supplied password and capacity; `LeaveChannel` returns to Lobby. Passwords use the +native salted, keyed BLAKE2b-256 `salt_hex:hash_hex` format, verified in both directions. + +Channel create/edit/delete persist before broadcasting events. Administrators can manage +all channels; `CanCreateTempChannel` permits creation of temporary channels only. Edit with +an empty password preserves the existing hash, matching native behavior; password removal +has no v2 request representation. Lobby cannot be deleted, protected or nested. Missing +parents, tree cycles and deletion of parents with children fail without mutation. Deletion +moves members to Lobby (even if full), clearing their streams. Edits stop existing streams +so clients must negotiate the updated audio configuration. Channel names/topics/passwords +are limited to 128/4096/1024 UTF-8 bytes. Audio requires Opus, 48 kHz, mono/stereo, +500–512000 bps, integral 5/10/20/40/60 ms frames and valid application/loss/complexity. +Database v2 has no DRED column; CRUD rejects DRED rather than silently losing it on restart. + +Session permissions gate kick/ban/move/mute and account operations. Only administrators +can grant permissions; account-administration permission cannot grant administrator status. +These two permission restrictions are stricter than the C++ oracle. Moves bypass channel +passwords but respect capacity and clear streams. Server mute/deafen immediately updates +encrypted routing. Kick/ban retire routing before closure and emit one LEFT with the reason. +Account bans persist by username; guest bans persist by address because nicknames are not +identities. Ban wire expiry is Unix milliseconds, converted to database seconds rounded up; +zero means permanent. This fixes the native handler's millisecond/second mismatch. +Existing sessions on the same address/account are not swept by a target-user ban. + +Create/reset/delete/list accounts require administrator or `CanAdminAccounts`. New accounts +are non-admin. Bounded Argon2 work runs outside the server state lock; authority is checked +when accepting the operation, and cancellation is checked before password writes. Reset +and deletion affect future authentication; existing sessions retain their permissions. +Lists omit password hashes and return millisecond timestamps. Oversized lists fail instead +of truncating or exceeding the 64 KiB frame limit. Privileged responses echo request ids; +generic codes are 6 for permission denied and 3 for invalid/missing/duplicate input. `VoiceServer.MediaEndPoint` exposes the bound UDP endpoint; UDP uses the same address and port number as TCP, and `ServerHello.udp_port` advertises it. Successful authentication @@ -236,8 +266,9 @@ the accept, reaper, control and media loops before disposing credentials/storage and rejects unknown revisions. Opening an existing channel table does not reseed it. Account creation/authentication uses parameterized SQL; two password workers bound per-store Argon2 work. Failed authentication leaves `last_login` unchanged. Dispose -after its operations finish. Account provisioning currently uses this API or the -existing native administration path; there is no automatic bootstrap account. +after its operations finish. `ResetPasswordAsync`, `DeleteAccount` and `ListAccounts` +also expose administration to hosts. Initial administrator provisioning uses this API or +the native administration CLI; there is no automatic bootstrap account. `PasswordHasher` uses strict UTF-8 without normalization and libsodium-compatible Argon2id v19 PHC strings: 16-byte salt, 32-byte output, new-hash parameters diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index 82653bb..7485753 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -750,7 +750,18 @@ the TCP-only idle timeout. Control envelopes, authenticated voice and bound-endp keepalives refresh shared monotonic activity; invalid media does not. Reaping removes presence and media routing, and can be disabled. Tests inject a clock to cover silent clients, UDP-only activity, forged media, single departure events and disabled expiry. -Administration, protected joins and production configuration remain before Phase 4 completion. +**Channel/administration checkpoint:** protected joins and channel CRUD now persist using +the native BLAKE2b password format (native verification in both directions). Permissions +gate moderation and account create/reset/delete/list. Mute/deafen/move update encrypted +routing; kick/ban retire media and emit one reason-bearing departure. Guest bans use +addresses, account bans use usernames, and wire milliseconds convert to database seconds. +Temporary-channel permission only creates temporary channels and only administrators +grant permissions; these deliberately tighten native policy. Tree validation and Lobby +protection prevent invalid mutations. Existing streams stop on channel edits/moves/deletion. +The C++ CLI creates protected channels and administers accounts against the managed server; +its channel argument lifetimes and default audio config were corrected. See api-dotnet.md +for limits, persistence and policy differences. Production configuration/publishing and +the remaining server readiness checks still precede Phase 4 completion. 1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry. 2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat diff --git a/dotnet/oracle/database.cpp b/dotnet/oracle/database.cpp index 11b1b38..bf89b4c 100644 --- a/dotnet/oracle/database.cpp +++ b/dotnet/oracle/database.cpp @@ -6,6 +6,22 @@ int main(int argc, char **argv) { voicecat::server::Database database(argv[2]); std::string error; if (!database.open(error)) return 1; + if (std::string(argv[1]) == "create-protected") { + voicecat::server::ChannelRecord channel; + channel.name = "Native protected"; + channel.audio.set_sample_rate(48000); + channel.audio.set_bitrate_bps(24000); + channel.audio.set_frame_ms(20); + return database.create_channel(channel, "channel password", error) ? 0 : 1; + } + if (std::string(argv[1]) == "verify-protected") { + for (const auto& channel : database.list_channels()) { + if (channel.name == "Managed protected") + return database.check_channel_password(channel.id, "channel password") && + !database.check_channel_password(channel.id, "wrong") ? 0 : 1; + } + return 1; + } if (std::string(argv[1]) == "create") { if (!database.create_account("legacy", "legacy password", true, error)) return 1; voicecat::server::ChannelRecord lobby; diff --git a/dotnet/src/VoiceCat.Server/Administration.cs b/dotnet/src/VoiceCat.Server/Administration.cs new file mode 100644 index 0000000..54ee866 --- /dev/null +++ b/dotnet/src/VoiceCat.Server/Administration.cs @@ -0,0 +1,101 @@ +using System.Text; +using Google.Protobuf; +using Microsoft.Data.Sqlite; +using Voicecat.V1; + +namespace VoiceCat.Server; + +public sealed partial class VoiceServer +{ + private void Moderate(Session actor, Envelope request) + { + lock (gate) + { + bool permitted = actor.Permissions.IsAdmin || request.BodyCase switch + { + Envelope.BodyOneofCase.Kick or Envelope.BodyOneofCase.ServerMute => actor.Permissions.CanKick, + Envelope.BodyOneofCase.Ban => actor.Permissions.CanBan, + Envelope.BodyOneofCase.MoveUser => actor.Permissions.CanMoveUsers, + // Granting arbitrary permissions (including admin) is reserved for administrators. + _ => false + }; + if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; } + uint id = request.Kick?.UserId ?? request.Ban?.UserId ?? request.MoveUser?.UserId ?? request.ServerMute?.UserId ?? request.SetPermission.UserId; + Session? target = sessions.Values.FirstOrDefault(p => !p.Closing && p.User?.Id == id); + if (target is null) { SendResult(actor, request.RequestId, false, 3, "User not found."); return; } + string reason = request.Kick?.Reason ?? request.Ban?.Reason ?? ""; + if (Encoding.UTF8.GetByteCount(reason) > 4096 || request.SetPermission is not null && request.SetPermission.Permissions is null) + { SendResult(actor, request.RequestId, false, 3, "Invalid moderation request."); return; } + if (request.MoveUser is not null) + { + Channel? destination = channels.FirstOrDefault(c => c.Id == request.MoveUser.ChannelId); + if (destination is null || destination.MaxUsers != 0 && sessions.Values.Count(p => p.Id != target.Id && p.User?.ChannelId == destination.Id) >= destination.MaxUsers) + { SendResult(actor, request.RequestId, false, 3, "Channel unavailable."); return; } + target.User!.ChannelId = destination.Id; + target.User.Streams.Clear(); + PublishMedia(); + BroadcastUser(target); + } + else if (request.ServerMute is not null) + { + target.User!.ServerMuted = request.ServerMute.Muted; + target.User.ServerDeafened = request.ServerMute.Deafened; + PublishMedia(); + BroadcastUser(target); + } + else if (request.SetPermission is not null) target.Permissions = request.SetPermission.Permissions.Clone(); + else + { + if (request.Ban is not null) + { + // Guest nicknames are not identities; ban their address instead of reserving a nickname. + accounts.Ban(target.User!.IsGuest ? "ip" : "username", target.User.IsGuest ? target.Address : target.User.Nickname, reason, request.Ban.ExpiresUnixMs); + } + target.DepartureReason = reason; + target.Closing = true; + PublishMedia(); + Reject(target, reason.Length == 0 ? "Removed by moderator." : reason); + } + SendResult(actor, request.RequestId, true, 0, ""); + } + } + + private async Task AdministerAccountsAsync(Session actor, Envelope request) + { + lock (gate) + { + if (!actor.Permissions.IsAdmin && !actor.Permissions.CanAdminAccounts) + { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; } + } + // Authority is checked when accepting the operation; bounded password work runs off the control loop. + try + { + string? username = request.CreateAccount?.Username ?? request.ResetPassword?.Username ?? request.DeleteAccount?.Username; + if (username is not null && (string.IsNullOrWhiteSpace(username) || username.Length > 128)) throw new ArgumentException("Invalid username."); + bool ok = true; + switch (request.BodyCase) + { + case Envelope.BodyOneofCase.CreateAccount: + await accounts.CreateAccountAsync(username!, request.CreateAccount!.Password, cancellationToken: actor.Connection.CancellationToken).ConfigureAwait(false); + break; + case Envelope.BodyOneofCase.ResetPassword: + ok = await accounts.ResetPasswordAsync(username!, request.ResetPassword!.NewPassword, actor.Connection.CancellationToken).ConfigureAwait(false); + break; + case Envelope.BodyOneofCase.DeleteAccount: ok = accounts.DeleteAccount(username!); break; + case Envelope.BodyOneofCase.ListAccounts: + var response = new Envelope { RequestId = request.RequestId, ListAccountsResult = new() }; + foreach (var account in accounts.ListAccounts()) + { + response.ListAccountsResult.Accounts.Add(new AccountEntry { Username = account.Username, IsAdmin = account.IsAdmin, + CreatedAtUnixMs = checked((ulong)account.CreatedAt * 1000), LastLoginUnixMs = checked((ulong)account.LastLogin * 1000) }); + if (response.CalculateSize() > 65536) { SendResult(actor, request.RequestId, false, 3, "Account list exceeds protocol frame limit."); return; } + } + actor.Connection.TrySend(response); + return; + } + SendResult(actor, request.RequestId, ok, ok ? 0U : 3U, ok ? "" : "Account not found."); + } + catch (ArgumentException) { SendResult(actor, request.RequestId, false, 3, "Invalid username or password."); } + catch (SqliteException exception) when (exception.SqliteErrorCode == 19) { SendResult(actor, request.RequestId, false, 3, "Account already exists or is invalid."); } + } +} diff --git a/dotnet/src/VoiceCat.Server/ChannelManagement.cs b/dotnet/src/VoiceCat.Server/ChannelManagement.cs new file mode 100644 index 0000000..010ba38 --- /dev/null +++ b/dotnet/src/VoiceCat.Server/ChannelManagement.cs @@ -0,0 +1,83 @@ +using System.Text; +using Microsoft.Data.Sqlite; +using Voicecat.V1; + +namespace VoiceCat.Server; + +public sealed partial class VoiceServer +{ + private void ManageChannel(Session actor, Envelope request) + { + lock (gate) + { + bool create = request.CreateChannel is not null; + Channel? input = create ? request.CreateChannel!.Channel : request.EditChannel?.Channel; + bool permitted = actor.Permissions.IsAdmin || create && actor.Permissions.CanCreateTempChannel && input?.Type == ChannelType.ChannelTemporary; + if (!permitted) { SendResult(actor, request.RequestId, false, 6, "Permission denied."); return; } + try + { + if (request.DeleteChannel is not null) + { + uint id = request.DeleteChannel.ChannelId; + if (id == 1 || !channels.Any(c => c.Id == id) || channels.Any(c => c.ParentId == id)) + throw new ArgumentException("Cannot delete Lobby, a missing channel, or a channel with children."); + accounts.DeleteChannel(id); + channels.RemoveAll(c => c.Id == id); + foreach (Session peer in sessions.Values.Where(p => p.User?.ChannelId == id)) + { + peer.User!.ChannelId = 1; + peer.User.Streams.Clear(); + BroadcastUser(peer); + } + PublishMedia(); + Broadcast(new() { ChannelEvent = new() { Kind = ChannelEvent.Types.Kind.Deleted, DeletedId = id } }); + } + else + { + string password = create ? request.CreateChannel!.Password : request.EditChannel!.Password; + ValidateChannel(input, password, create); + Channel saved = accounts.SaveChannel(input!, password, create); + if (create) channels.Add(saved); + else channels[channels.FindIndex(c => c.Id == saved.Id)] = saved; + // Existing encoders negotiated the previous configuration. Stop their streams on edits. + if (!create) + { + foreach (Session peer in sessions.Values.Where(p => p.User?.ChannelId == saved.Id)) + { + peer.User!.Streams.Clear(); + BroadcastUser(peer); + } + PublishMedia(); + } + Broadcast(new() { ChannelEvent = new() { Kind = create ? ChannelEvent.Types.Kind.Created : ChannelEvent.Types.Kind.Updated, Channel = saved.Clone() } }); + } + SendResult(actor, request.RequestId, true, 0, ""); + } + catch (ArgumentException exception) { SendResult(actor, request.RequestId, false, 3, exception.Message); } + catch (SqliteException exception) when (exception.SqliteErrorCode == 19) { SendResult(actor, request.RequestId, false, 3, "Channel name already exists or channel is invalid."); } + } + } + + private void ValidateChannel(Channel? channel, string password, bool create) + { + var a = channel?.Audio; + if (channel is null || string.IsNullOrWhiteSpace(channel.Name) || Encoding.UTF8.GetByteCount(channel.Name) > 128 || + Encoding.UTF8.GetByteCount(channel.Topic) > 4096 || Encoding.UTF8.GetByteCount(password) > 1024 || !Enum.IsDefined(channel.Type) || + channel.MaxUsers > int.MaxValue || a is null || a.Codec != 0 || !Enum.IsDefined(a.Mode) || !Enum.IsDefined(a.Application) || + a.SampleRate != 48000 || a.BitrateBps is < 500 or > 512000 || a.FrameMs is not (5 or 10 or 20 or 40 or 60) || + a.Complexity > 10 || a.ExpectedPacketLoss > 100 || a.Dred || + !create && !channels.Any(c => c.Id == channel.Id) || channel.ParentId != 0 && !channels.Any(c => c.Id == channel.ParentId)) + throw new ArgumentException("Invalid channel or audio configuration (database v2 cannot persist DRED)."); + if (channel.Id == 1 && !create && (password.Length != 0 || channel.ParentId != 0)) throw new ArgumentException("Lobby must remain an unprotected root channel."); + uint parent = channel.ParentId; + var visited = new HashSet(); + while (parent != 0) + { + if (!visited.Add(parent) || !create && parent == channel.Id) throw new ArgumentException("Channel tree cannot contain cycles."); + parent = channels.First(c => c.Id == parent).ParentId; + } + } + + private static void SendResult(Session actor, ulong id, bool ok, uint code, string message) => + actor.Connection.TrySend(new() { RequestId = id, GenericResult = new() { Ok = ok, Code = code, Message = message } }); +} diff --git a/dotnet/src/VoiceCat.Server/Data/AccountAdministration.cs b/dotnet/src/VoiceCat.Server/Data/AccountAdministration.cs new file mode 100644 index 0000000..a9678f7 --- /dev/null +++ b/dotnet/src/VoiceCat.Server/Data/AccountAdministration.cs @@ -0,0 +1,50 @@ +namespace VoiceCat.Server.Data; + +public sealed partial class AccountStore +{ + public async Task ResetPasswordAsync(string username, string password, CancellationToken cancellationToken = default) + { + string hash = await PasswordWorkAsync(() => hasher.Hash(password), cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + using var connection = Open(); + using var command = connection.CreateCommand(); + command.CommandText = "UPDATE accounts SET pw_hash=$hash WHERE username=$user"; + command.Parameters.AddWithValue("$hash", hash); + command.Parameters.AddWithValue("$user", username); + return command.ExecuteNonQuery() == 1; + } + + public bool DeleteAccount(string username) + { + using var connection = Open(); + using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM accounts WHERE username=$user"; + command.Parameters.AddWithValue("$user", username); + return command.ExecuteNonQuery() == 1; + } + + public IReadOnlyList ListAccounts() + { + using var connection = Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id,username,is_admin,created_at,last_login FROM accounts ORDER BY username"; + using var reader = command.ExecuteReader(); + var result = new List(); + while (reader.Read()) result.Add(new(reader.GetInt64(0), reader.GetString(1), reader.GetBoolean(2), reader.GetInt64(3), reader.GetInt64(4))); + return result; + } + + internal void Ban(string type, string subject, string reason, ulong expiresUnixMs) + { + using var connection = Open(); + using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO bans (subject_type,subject,reason,expires_at,created_at) VALUES ($type,$subject,$reason,$expires,$created)"; + command.Parameters.AddWithValue("$type", type); + command.Parameters.AddWithValue("$subject", subject); + command.Parameters.AddWithValue("$reason", reason); + // Native schema timestamps are seconds; round upwards to avoid expiring early. + command.Parameters.AddWithValue("$expires", checked((long)(expiresUnixMs / 1000 + (expiresUnixMs % 1000 == 0 ? 0UL : 1UL)))); + command.Parameters.AddWithValue("$created", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + command.ExecuteNonQuery(); + } +} diff --git a/dotnet/src/VoiceCat.Server/Data/AccountStore.cs b/dotnet/src/VoiceCat.Server/Data/AccountStore.cs index 449975c..5134d6d 100644 --- a/dotnet/src/VoiceCat.Server/Data/AccountStore.cs +++ b/dotnet/src/VoiceCat.Server/Data/AccountStore.cs @@ -6,7 +6,7 @@ namespace VoiceCat.Server.Data; public sealed record Account(long Id, string Username, bool IsAdmin, long CreatedAt, long LastLogin); -public sealed class AccountStore : IDisposable +public sealed partial class AccountStore : IDisposable { static AccountStore() => SQLitePCL.Batteries_V2.Init(); private readonly string connectionString; diff --git a/dotnet/src/VoiceCat.Server/Data/ChannelStore.cs b/dotnet/src/VoiceCat.Server/Data/ChannelStore.cs new file mode 100644 index 0000000..52db1db --- /dev/null +++ b/dotnet/src/VoiceCat.Server/Data/ChannelStore.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using System.Text; +using Org.BouncyCastle.Crypto.Digests; +using Voicecat.V1; + +namespace VoiceCat.Server.Data; + +public sealed partial class AccountStore +{ + private static byte[] ChannelDigest(string password, byte[] salt) + { + var digest = new Blake2bDigest(salt, 32, null, null); + byte[] bytes = Encoding.UTF8.GetBytes(password); + byte[] hash = new byte[32]; + try { digest.BlockUpdate(bytes, 0, bytes.Length); digest.DoFinal(hash, 0); return hash; } + finally { CryptographicOperations.ZeroMemory(bytes); } + } + + public bool CheckChannelPassword(uint id, string password) + { + using var connection = Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT password_hash FROM channels WHERE id=$id"; + command.Parameters.AddWithValue("$id", id); + if (command.ExecuteScalar() is not string stored) return false; + if (stored.Length == 0) return true; + if (stored.Length != 97 || stored[32] != ':') return false; + try + { + byte[] salt = Convert.FromHexString(stored[..32]); + return CryptographicOperations.FixedTimeEquals(ChannelDigest(password, salt), Convert.FromHexString(stored[33..])); + } + catch (FormatException) { return false; } + } + + internal Channel SaveChannel(Channel channel, string password, bool create) + { + using var connection = Open(); + using var command = connection.CreateCommand(); + string hash = ""; + if (password.Length != 0) + { + byte[] salt = RandomNumberGenerator.GetBytes(16); + hash = Convert.ToHexString(salt).ToLowerInvariant() + ":" + Convert.ToHexString(ChannelDigest(password, salt)).ToLowerInvariant(); + } + string[] columns = ["parent_id", "name", "topic", "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"]; + var a = channel.Audio; + object[] values = [channel.ParentId, channel.Name, channel.Topic, channel.MaxUsers, (int)channel.Type, channel.Order, a.Codec, (int)a.Mode, a.SampleRate, a.BitrateBps, a.FrameMs, (int)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity]; + for (int i = 0; i < columns.Length; i++) command.Parameters.AddWithValue("$" + columns[i], values[i]); + command.Parameters.AddWithValue("$hash", hash); + command.Parameters.AddWithValue("$id", channel.Id); + command.CommandText = create + ? $"INSERT INTO channels ({string.Join(',', columns)},password_hash) VALUES ({string.Join(',', columns.Select(c => "$" + c))},$hash) RETURNING id" + : $"UPDATE channels SET {string.Join(',', columns.Select(c => c + "=$" + c))},password_hash=CASE WHEN $hash='' THEN password_hash ELSE $hash END WHERE id=$id RETURNING id"; + var saved = channel.Clone(); + saved.Id = checked((uint)(long)(command.ExecuteScalar() ?? throw new InvalidDataException("Channel not found."))); + saved.PasswordProtected = hash.Length != 0 || !create && CheckChannelPasswordPresent(saved.Id); + return saved; + } + + private bool CheckChannelPasswordPresent(uint id) + { + using var connection = Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT length(password_hash)>0 FROM channels WHERE id=$id"; + command.Parameters.AddWithValue("$id", id); + return (long)command.ExecuteScalar()! != 0; + } + + internal void DeleteChannel(uint id) + { + using var connection = Open(); + using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM channels WHERE id=$id"; + command.Parameters.AddWithValue("$id", id); + command.ExecuteNonQuery(); + } +} diff --git a/dotnet/src/VoiceCat.Server/VoiceServer.cs b/dotnet/src/VoiceCat.Server/VoiceServer.cs index 0574f41..371ccde 100644 --- a/dotnet/src/VoiceCat.Server/VoiceServer.cs +++ b/dotnet/src/VoiceCat.Server/VoiceServer.cs @@ -10,13 +10,13 @@ using Voicecat.V1; namespace VoiceCat.Server; -public sealed class VoiceServer : IAsyncDisposable +public sealed partial class VoiceServer : IAsyncDisposable { private readonly Socket listener; private readonly MediaRelay media; private readonly ServerCredentials credentials; private readonly AccountStore accounts; - private readonly IReadOnlyList channels; + private readonly List channels; private readonly bool allowGuests; private readonly string name; private readonly VoiceServerOptions options; @@ -51,7 +51,7 @@ public sealed class VoiceServer : IAsyncDisposable try { accounts = new AccountStore(Path.Combine(directory, "voicecat.db")); - channels = accounts.LoadChannels(); + channels = accounts.LoadChannels().ToList(); listener = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); listener.Bind(endpoint); listener.Listen(options.MaximumConnections); @@ -99,6 +99,7 @@ public sealed class VoiceServer : IAsyncDisposable { await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false)) { + if (session.Closing) break; session.Activity.Touch(); if (envelope.Ping is not null) { @@ -131,7 +132,20 @@ public sealed class VoiceServer : IAsyncDisposable { case Envelope.BodyOneofCase.TextMessage: RelayText(session, envelope.TextMessage); break; case Envelope.BodyOneofCase.Subscribe: SendSnapshot(session); break; - case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId); break; + case Envelope.BodyOneofCase.JoinChannel: Join(session, envelope.RequestId, envelope.JoinChannel.ChannelId, envelope.JoinChannel.Password); break; + case Envelope.BodyOneofCase.LeaveChannel: Join(session, envelope.RequestId, 1); break; + case Envelope.BodyOneofCase.CreateChannel: + case Envelope.BodyOneofCase.EditChannel: + case Envelope.BodyOneofCase.DeleteChannel: ManageChannel(session, envelope); break; + case Envelope.BodyOneofCase.Kick: + case Envelope.BodyOneofCase.Ban: + case Envelope.BodyOneofCase.MoveUser: + case Envelope.BodyOneofCase.ServerMute: + case Envelope.BodyOneofCase.SetPermission: Moderate(session, envelope); break; + case Envelope.BodyOneofCase.CreateAccount: + case Envelope.BodyOneofCase.ResetPassword: + case Envelope.BodyOneofCase.DeleteAccount: + case Envelope.BodyOneofCase.ListAccounts: await AdministerAccountsAsync(session, envelope).ConfigureAwait(false); break; case Envelope.BodyOneofCase.SubscribeVoice: SubscribeVoice(session, envelope.RequestId, true); break; case Envelope.BodyOneofCase.UnsubscribeVoice: SubscribeVoice(session, envelope.RequestId, false); break; case Envelope.BodyOneofCase.StreamAnnounce: AnnounceStream(session, envelope.RequestId, envelope.StreamAnnounce); break; @@ -159,7 +173,7 @@ public sealed class VoiceServer : IAsyncDisposable sessions.Remove(session.Id); if (session.User is null) session.Media?.Dispose(); else PublishMedia(); - if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id } }); + if (session.User is not null) Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Left, LeftId = session.User.Id, Reason = session.DepartureReason } }); } await session.Connection.DisposeAsync().ConfigureAwait(false); } @@ -208,6 +222,7 @@ public sealed class VoiceServer : IAsyncDisposable session.Connection.CancellationToken.ThrowIfCancellationRequested(); lock (gate) { + if (session.Closing) return; 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) { @@ -216,10 +231,11 @@ public sealed class VoiceServer : IAsyncDisposable } user.Id = checked(++nextUser); session.User = user; + session.Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin }; session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new() { Ok = true, SessionId = session.Id, Self = user.Clone(), UdpToken = ByteString.CopyFrom(session.Media!.Token), - Permissions = new() { IsAdmin = admin, CanAdminAccounts = admin, CanBan = admin, CanKick = admin, CanMoveUsers = admin, CanCreateTempChannel = admin } + Permissions = session.Permissions.Clone() } }); PublishMedia(); Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id); @@ -238,12 +254,12 @@ public sealed class VoiceServer : IAsyncDisposable } } - private void Join(Session session, ulong requestId, uint channelId) + private void Join(Session session, ulong requestId, uint channelId, string password = "") { 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) + if (channel is null || Encoding.UTF8.GetByteCount(password) > 1024 || !accounts.CheckChannelPassword(channelId, password) || 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; @@ -282,7 +298,7 @@ public sealed class VoiceServer : IAsyncDisposable private void PublishMedia() { - media.Publish(sessions.Values.Where(peer => peer.User is not null).Select(peer => new MediaRoute( + media.Publish(sessions.Values.Where(peer => peer.User is not null && !peer.Closing).Select(peer => new MediaRoute( peer.Media!, peer.User!.ChannelId, peer.User.VoiceSubscribed, peer.User.ServerMuted, peer.User.SelfDeafened || peer.User.ServerDeafened, peer.User.Streams.Select(stream => stream.Ssrc).ToArray())).ToArray()); } @@ -380,8 +396,10 @@ public sealed class VoiceServer : IAsyncDisposable public string Address { get; } = address; public SessionActivity Activity { get; } = activity; public bool Closing { get; set; } + public string DepartureReason { get; set; } = ""; public bool HelloReceived { get; set; } public User? User { get; set; } + public Permissions Permissions { get; set; } = new(); public MediaPeer? Media { get; set; } public uint NextStream; } diff --git a/dotnet/tests/VoiceCat.Tests/AccountStoreTests.cs b/dotnet/tests/VoiceCat.Tests/AccountStoreTests.cs index 04ed28a..a7076a0 100644 --- a/dotnet/tests/VoiceCat.Tests/AccountStoreTests.cs +++ b/dotnet/tests/VoiceCat.Tests/AccountStoreTests.cs @@ -62,6 +62,28 @@ public sealed class AccountStoreTests finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } } } + [NativeDatabaseFact] + public async Task ChannelPasswordHashesWorkInBothImplementations() + { + string directory = Path.Combine(Path.GetTempPath(), "voicecat-channels-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, "voicecat.db"); + try + { + await RunOracleAsync("create-protected", path); + using (var store = new AccountStore(path)) + { + var channel = Assert.Single(store.LoadChannels()); + Assert.True(store.CheckChannelPassword(channel.Id, "channel password")); + Assert.False(store.CheckChannelPassword(channel.Id, "wrong")); + channel.Name = "Managed protected"; + store.SaveChannel(channel, "channel password", true); + } + await RunOracleAsync("verify-protected", path); + } + finally { Directory.Delete(directory, true); } + } + private sealed class NativeDatabaseFactAttribute : FactAttribute { public NativeDatabaseFactAttribute() diff --git a/dotnet/tests/VoiceCat.Tests/AdministrationTests.cs b/dotnet/tests/VoiceCat.Tests/AdministrationTests.cs new file mode 100644 index 0000000..19ce0ea --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/AdministrationTests.cs @@ -0,0 +1,171 @@ +using VoiceCat.Server.Data; +using Voicecat.V1; +using static VoiceCat.Tests.ServerTests; +using static VoiceCat.Tests.ChannelManagementTests; +using static VoiceCat.Tests.MediaRelayTests; + +namespace VoiceCat.Tests; + +public class AdministrationTests +{ + [NativeCliFact] + public async Task ExistingCppCliCreatesProtectedChannelsAndAdministersAccounts() + { + await using var fixture = new ServerFixture(); + await using var admin = await AdminAsync(fixture); + var start = new System.Diagnostics.ProcessStartInfo(Environment.GetEnvironmentVariable("VOICECAT_VCCLI")!) + { + WorkingDirectory = fixture.Directory, UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true + }; + foreach (string argument in new[] { "--host", "127.0.0.1", "--port", fixture.Server.EndPoint.Port.ToString(), "--username", "Admin", "--password", "secret", + "--create-channel", "--new-channel-name", "Native room", "--new-channel-password", "protected", "--create-account", "native", "secret", "--list-accounts", "--wait-ms", "10000" }) + start.ArgumentList.Add(argument); + using var process = System.Diagnostics.Process.Start(start)!; + Task stdout = process.StandardOutput.ReadToEndAsync(), stderr = process.StandardError.ReadToEndAsync(); + try + { + await process.WaitForExitAsync(admin.Timeout.Token); + Assert.True(process.ExitCode == 0, await stdout + await stderr); + using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")); + var room = store.LoadChannels().Single(c => c.Name == "Native room"); + Assert.True(store.CheckChannelPassword(room.Id, "protected")); + Assert.NotNull(await store.AuthenticateAsync("native", "secret")); + } + finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } } + } + + private sealed class NativeCliFactAttribute : FactAttribute + { + public NativeCliFactAttribute() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_VCCLI"))) Skip = "Set VOICECAT_VCCLI to the existing native CLI."; + } + } + + [Fact] + public async Task AccountAdministrationIsPermissionGatedAndPersistsPasswordChanges() + { + await using var fixture = new ServerFixture(); + await using var guest = await fixture.ConnectAsync(); + User user = await guest.LoginAsync("Guest"); + await using var admin = await AdminAsync(fixture); + Assert.False(await ResultAsync(guest, new() { ListAccounts = new() })); + Assert.False(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "secret" } })); + Assert.False(await ResultAsync(guest, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { IsAdmin = true } } })); + Assert.True(await ResultAsync(admin, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { CanAdminAccounts = true, CanCreateTempChannel = true } } })); + Assert.True(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "first" } })); + Assert.False(await ResultAsync(guest, new() { CreateAccount = new() { Username = "new", Password = "first" } })); + Assert.False(await ResultAsync(guest, new() { SetPermission = new() { UserId = user.Id, Permissions = new() { IsAdmin = true } } })); + Assert.False(await ResultAsync(guest, new() { CreateChannel = new() { Channel = new() { Name = "Permanent" } } })); + Assert.True(await ResultAsync(guest, new() { CreateChannel = new() { Channel = new() { Name = "Temporary", Type = ChannelType.ChannelTemporary, + Audio = new() { SampleRate = 48000, BitrateBps = 24000, FrameMs = 20 } } } })); + Assert.True(await ResultAsync(guest, new() { ResetPassword = new() { Username = "new", NewPassword = "second" } })); + Assert.False(await ResultAsync(guest, new() { ResetPassword = new() { Username = "missing", NewPassword = "second" } })); + using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) + { + Assert.Null(await store.AuthenticateAsync("new", "first")); + Assert.NotNull(await store.AuthenticateAsync("new", "second")); + } + guest.Send(new() { RequestId = 50, ListAccounts = new() }); + Envelope list = await guest.ReadUntilAsync(e => e.ListAccountsResult is not null); + Assert.Equal(50UL, list.RequestId); + Assert.Equal(2, list.ListAccountsResult.Accounts.Count); + var entry = list.ListAccountsResult.Accounts.Single(a => a.Username == "new"); + Assert.False(entry.IsAdmin); + Assert.True(entry.CreatedAtUnixMs > 1_000_000_000_000); + Assert.True(entry.LastLoginUnixMs > 1_000_000_000_000); + Assert.True(await ResultAsync(guest, new() { DeleteAccount = new() { Username = "new" } })); + Assert.False(await ResultAsync(guest, new() { DeleteAccount = new() { Username = "new" } })); + using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")); + Assert.Null(await reopened.AuthenticateAsync("new", "second")); + } + + [Fact] + public async Task AccountBanPersistsByUsernameAndBlocksNewAuthentication() + { + await using var fixture = new ServerFixture(); + await using var admin = await AdminAsync(fixture); + using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")); + await store.CreateAccountAsync("Member", "password"); + await using var member = await fixture.ConnectAsync(); + member.Send(new() { ClientHello = new() { ProtoVersion = 2 } }); + await member.ReadUntilAsync(e => e.ServerHello is not null); + member.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = "password" } } }); + AuthResult auth = (await member.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult; + Assert.True(auth.Ok); + Assert.True(await ResultAsync(admin, new() { Ban = new() { UserId = auth.Self.Id, Reason = "account banned" } })); + Assert.NotNull((await member.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect); + Assert.True(store.IsBanned("username", "Member")); + Assert.False(store.IsBanned("ip", "127.0.0.1")); + await using var retry = await fixture.ConnectAsync(); + retry.Send(new() { ClientHello = new() { ProtoVersion = 2 } }); + await retry.ReadUntilAsync(e => e.ServerHello is not null); + retry.Send(new() { AuthRequest = new() { Password = new() { Username = "Member", Password = "password" } } }); + Assert.False((await retry.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok); + Assert.False(await ResultAsync(admin, new() { Kick = new() { UserId = uint.MaxValue } })); + } + + [Fact] + public async Task ServerMuteDeafenAndMoveImmediatelyChangeEncryptedMediaRouting() + { + await using var fixture = new ServerFixture(); + await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice"); + await using var bob = await VoicePeer.ConnectAsync(fixture, "Bob"); + await using var admin = await AdminAsync(fixture); + uint a = alice.Client.Authentication!.Self.Id, b = bob.Client.Authentication!.Self.Id; + var stream = await alice.AnnounceAsync(StreamKind.StreamMic); + Assert.False(await ResultAsync(bob.Client, new() { ServerMute = new() { UserId = a, Muted = true } })); + Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = a, Muted = true } })); + await alice.SendAsync(alice.Seal(stream.Ssrc, [1])); await bob.AssertNoVoiceAsync(); + Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = a } })); + Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = b, Deafened = true } })); + await alice.SendAsync(alice.Seal(stream.Ssrc, [2])); await bob.AssertNoVoiceAsync(); + Assert.True(await ResultAsync(admin, new() { ServerMute = new() { UserId = b } })); + await alice.SendAsync(alice.Seal(stream.Ssrc, [3])); Assert.Equal(new byte[] { 3 }, (await bob.ReceiveVoiceAsync()).Payload); + Assert.True(await ResultAsync(admin, new() { MoveUser = new() { UserId = a, ChannelId = 2 } })); + await alice.SendAsync(alice.Seal(stream.Ssrc, [4])); await bob.AssertNoVoiceAsync(); + Assert.True(await ResultAsync(admin, new() { MoveUser = new() { UserId = a, ChannelId = 1 } })); + await alice.SendAsync(alice.Seal(stream.Ssrc, [5])); await bob.AssertNoVoiceAsync(); + var replacement = await alice.AnnounceAsync(StreamKind.StreamMic); + await alice.SendAsync(alice.Seal(replacement.Ssrc, [6])); Assert.Equal(new byte[] { 6 }, (await bob.ReceiveVoiceAsync()).Payload); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task KickAndGuestBanDisconnectWithOneDepartureAndRetireMedia(bool ban) + { + await using var fixture = new ServerFixture(); + await using var alice = await VoicePeer.ConnectAsync(fixture, "Alice"); + await using var observer = await VoicePeer.ConnectAsync(fixture, "Observer"); + await using var admin = await AdminAsync(fixture); + uint id = alice.Client.Authentication!.Self.Id; + var stream = await alice.AnnounceAsync(StreamKind.StreamMic); + Envelope request = ban ? new() { Ban = new() { UserId = id, Reason = "removed", ExpiresUnixMs = (ulong)DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeMilliseconds() } } + : new() { Kick = new() { UserId = id, Reason = "removed" } }; + Assert.True(await ResultAsync(admin, request)); + Assert.Equal("removed", (await alice.Client.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect.Reason); + var left = (await observer.Client.ReadUntilAsync(e => e.UserEvent?.LeftId == id)).UserEvent; + Assert.Equal("removed", left.Reason); + observer.Client.Send(new() { Ping = new() { Nonce = 99 } }); + while (true) + { + Envelope message = await observer.Client.ReadUntilAsync(_ => true); + Assert.False(message.UserEvent?.LeftId == id); + if (message.Pong?.Nonce == 99) break; + } + await alice.SendAsync(alice.Seal(stream.Ssrc, [1])); await observer.AssertNoVoiceAsync(); + await using var reconnect = await fixture.ConnectAsync(); + if (ban) + { + reconnect.Send(new() { ClientHello = new() { ProtoVersion = 2 } }); + Assert.NotNull((await reconnect.ReadUntilAsync(e => e.Disconnect is not null)).Disconnect); + using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")); + Assert.True(store.IsBanned("ip", "127.0.0.1")); + store.Ban("username", "expired", "", 1); + Assert.False(store.IsBanned("username", "expired")); + } + else await reconnect.LoginAsync("Alice"); + } +} diff --git a/dotnet/tests/VoiceCat.Tests/ChannelManagementTests.cs b/dotnet/tests/VoiceCat.Tests/ChannelManagementTests.cs new file mode 100644 index 0000000..fd7dcc3 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/ChannelManagementTests.cs @@ -0,0 +1,102 @@ +using VoiceCat.Server.Data; +using Voicecat.V1; +using static VoiceCat.Tests.ServerTests; + +namespace VoiceCat.Tests; + +public class ChannelManagementTests +{ + internal static async Task AdminAsync(ServerFixture fixture) + { + using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) + await store.CreateAccountAsync("Admin", "secret", true); + Client client = await fixture.ConnectAsync(); + client.Send(new() { ClientHello = new() { ProtoVersion = 2 } }); + await client.ReadUntilAsync(e => e.ServerHello is not null); + client.Send(new() { AuthRequest = new() { Password = new() { Username = "Admin", Password = "secret" } } }); + Assert.True((await client.ReadUntilAsync(e => e.AuthResult is not null)).AuthResult.Ok); + await client.ReadUntilAsync(e => e.ServerState is not null); + return client; + } + + private static Channel Room(string name = "Protected") => new() + { + Name = name, MaxUsers = 1, + Audio = new() { SampleRate = 48000, BitrateBps = 24000, FrameMs = 20, Complexity = 5, Fec = true } + }; + + internal static async Task ResultAsync(Client client, Envelope request) + { + request.RequestId = 42; + client.Send(request); + Envelope result = await client.ReadUntilAsync(e => e.GenericResult is not null); + Assert.Equal(42UL, result.RequestId); + return result.GenericResult.Ok; + } + + [Fact] + public async Task ProtectedChannelCrudEnforcesPasswordCapacityAndMovesMembersToLobby() + { + await using var fixture = new ServerFixture(); + await using var guest = await fixture.ConnectAsync(); + User user = await guest.LoginAsync("Guest"); + await using var admin = await AdminAsync(fixture); + Assert.False(await ResultAsync(guest, new() { CreateChannel = new() { Channel = Room() } })); + Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room(), Password = "pāssword" } })); + Channel room = (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Created)).ChannelEvent.Channel; + Assert.True(room.PasswordProtected); + foreach (string password in new[] { "", "wrong", "pāssword" }) + { + guest.Send(new() { RequestId = 7, JoinChannel = new() { ChannelId = room.Id, Password = password } }); + Envelope result = await guest.ReadUntilAsync(e => e.JoinChannelResult is not null); + Assert.Equal(7UL, result.RequestId); + Assert.Equal(password == "pāssword", result.JoinChannelResult.Ok); + } + admin.Send(new() { JoinChannel = new() { ChannelId = room.Id, Password = "pāssword" } }); + Assert.False((await admin.ReadUntilAsync(e => e.JoinChannelResult is not null)).JoinChannelResult.Ok); + room.Name = "Renamed"; + Assert.False(await ResultAsync(guest, new() { EditChannel = new() { Channel = room } })); + Assert.True(await ResultAsync(admin, new() { EditChannel = new() { Channel = room } })); + Assert.Equal("Renamed", (await guest.ReadUntilAsync(e => e.ChannelEvent is not null)).ChannelEvent.Channel.Name); + using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db"))) + { + Assert.Equal("Renamed", store.LoadChannels().Single(c => c.Id == room.Id).Name); + Assert.True(store.CheckChannelPassword(room.Id, "pāssword")); + Assert.False(store.CheckChannelPassword(room.Id, "wrong")); + } + Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = room.Id } })); + Assert.Equal(room.Id, (await guest.ReadUntilAsync(e => e.ChannelEvent?.Kind == ChannelEvent.Types.Kind.Deleted)).ChannelEvent.DeletedId); + guest.Send(new() { Subscribe = new() }); + var snapshot = (await guest.ReadUntilAsync(e => e.ServerState is not null)).ServerState; + Assert.Equal(1U, snapshot.Users.Single(u => u.Id == user.Id).ChannelId); + Assert.DoesNotContain(snapshot.Channels, c => c.Id == room.Id); + using var reopened = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")); + Assert.DoesNotContain(reopened.LoadChannels(), c => c.Id == room.Id); + } + + [Fact] + public async Task InvalidChangesCannotCorruptChannelTreeOrLobby() + { + await using var fixture = new ServerFixture(); + await using var admin = await AdminAsync(fixture); + Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Parent") } })); + using var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")); + Channel parent = store.LoadChannels().Single(c => c.Name == "Parent"); + Channel child = Room("Child"); child.ParentId = parent.Id; + Assert.True(await ResultAsync(admin, new() { CreateChannel = new() { Channel = child } })); + child = store.LoadChannels().Single(c => c.Name == "Child"); + parent.ParentId = child.Id; + Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = parent } })); + Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } })); + Assert.False(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = 1 } })); + Channel lobby = store.LoadChannels().Single(c => c.Id == 1); + Assert.False(await ResultAsync(admin, new() { EditChannel = new() { Channel = lobby, Password = "lockout" } })); + Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = Room("Child") } })); + var invalid = Room("Invalid"); invalid.Audio.SampleRate = 123; + Assert.False(await ResultAsync(admin, new() { CreateChannel = new() { Channel = invalid } })); + Assert.False(await ResultAsync(admin, new() { CreateChannel = new() })); + Assert.Equal(4, store.LoadChannels().Count); + Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = child.Id } })); + Assert.True(await ResultAsync(admin, new() { DeleteChannel = new() { ChannelId = parent.Id } })); + } +} diff --git a/tests/test_channel_samplerate.cpp b/tests/test_channel_samplerate.cpp index 0150869..af07a50 100644 --- a/tests/test_channel_samplerate.cpp +++ b/tests/test_channel_samplerate.cpp @@ -86,6 +86,7 @@ static bool connect_guest(vc_client*& client, const char* name, const char* labe if (!client) return false; ev.client = client; ev.label = label; + if (vc_set_external_playback(client, 1) != VC_OK) return false; if (vc_connect(client, "127.0.0.1", port) != VC_OK) return false; if (vc_authenticate_guest(client, name) != VC_OK) return false; if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false; @@ -163,6 +164,7 @@ static int64_t run_case(uint16_t port, vc_client* admin, EventStore& evAdmin, CHECK(connect_guest(clientA, "SrA", "sr-a", port, evA)); CHECK(connect_guest(clientB, "SrB", "sr-b", port, evB)); int64_t energy = -1; + SinkData sink; // Outlives both clients and their final audio callbacks. if (!clientA || !clientB) goto cleanup; { @@ -174,11 +176,11 @@ static int64_t run_case(uint16_t port, vc_client* admin, EventStore& evAdmin, CHECK(vc_move_user(admin, b_uid, channel_id) == VC_OK); std::this_thread::sleep_for(std::chrono::milliseconds(500)); - SinkData sink; CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK); vc_stream_desc desc{}; desc.kind = VC_STREAM_MIC; + desc.external_feed = 1; // Measure only the injected tone, never the host microphone. uint32_t a_sid = 0; CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK); @@ -201,7 +203,7 @@ static int64_t run_case(uint16_t port, vc_client* admin, EventStore& evAdmin, CHECK(sink_wait(sink, 5000)); std::this_thread::sleep_for(std::chrono::milliseconds(1500)); - energy = sink.total_energy; + { std::lock_guard lk(sink.mu); energy = sink.total_energy; } std::printf("test_channel_samplerate[%s]: sr=%u calls=%d energy=%lld\n", tag, expect_sr, sink.call_count.load(), static_cast(energy)); CHECK(sink.call_count.load() > 0); diff --git a/tools/vccli/src/main.cpp b/tools/vccli/src/main.cpp index d313517..92472b3 100644 --- a/tools/vccli/src/main.cpp +++ b/tools/vccli/src/main.cpp @@ -375,6 +375,14 @@ int main(int argc, char** argv) { bool do_delete_channel = false; uint32_t delete_channel_id = 0; vc_channel_info channel_info{}; + std::string new_channel_name, new_channel_topic, new_channel_password; + channel_info.audio.sample_rate = 48000; + channel_info.audio.bitrate_bps = 24000; + channel_info.audio.frame_ms = 20; + channel_info.audio.fec = 1; + channel_info.audio.expected_packet_loss = 10; + channel_info.audio.dtx = 1; + channel_info.audio.complexity = 5; // Account management bool do_create_account = false; @@ -453,11 +461,12 @@ int main(int argc, char** argv) { else if (a == "--edit-channel") do_edit_channel = true; else if (a == "--delete-channel") { do_delete_channel = true; if (!parse_u32(next().c_str(), &delete_channel_id, "--delete-channel")) return 1; } else if (a == "--channel-id") { if (!parse_u32(next().c_str(), &channel_info.id, "--channel-id")) return 1; } - else if (a == "--new-channel-name") channel_info.name = next().c_str(); - else if (a == "--new-channel-topic") channel_info.topic = next().c_str(); + else if (a == "--new-channel-name") { new_channel_name = next(); channel_info.name = new_channel_name.c_str(); } + else if (a == "--new-channel-topic") { new_channel_topic = next(); channel_info.topic = new_channel_topic.c_str(); } else if (a == "--new-channel-parent") { if (!parse_u32(next().c_str(), &channel_info.parent_id, "--new-channel-parent")) return 1; } else if (a == "--new-channel-password") { - channel_info.password = next().c_str(); + new_channel_password = next(); + channel_info.password = new_channel_password.c_str(); channel_info.password_protected = 1; } else if (a == "--new-channel-max-users") { if (!parse_u32(next().c_str(), &channel_info.max_users, "--new-channel-max-users")) return 1; }