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
@@ -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;
}