Add managed channel administration and moderation
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-15 23:11:09 +02:00
parent 274b85025c
commit 653131b876
15 changed files with 737 additions and 20 deletions
+23
View File
@@ -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.
+35 -4
View File
@@ -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,
500512000 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
+12 -1
View File
@@ -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
+16
View File
@@ -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;
@@ -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."); }
}
}
@@ -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<uint>();
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 } });
}
@@ -0,0 +1,50 @@
namespace VoiceCat.Server.Data;
public sealed partial class AccountStore
{
public async Task<bool> 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<Account> 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<Account>();
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();
}
}
@@ -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;
@@ -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();
}
}
+27 -9
View File
@@ -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<Voicecat.V1.Channel> channels;
private readonly List<Voicecat.V1.Channel> 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;
}
@@ -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()
@@ -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<string> 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");
}
}
@@ -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<Client> 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<bool> 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 } }));
}
}
+4 -2
View File
@@ -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<long long>(energy));
CHECK(sink.call_count.load() > 0);
+12 -3
View File
@@ -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; }