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
+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 } }));
}
}