Add managed channel administration and moderation
This commit is contained in:
+23
@@ -10,6 +10,29 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## ▶ Where we left off / next action
|
## ▶ 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
|
- **Done (2026-09-15): Managed media-aware reaper.** Voice checkpoint committed as
|
||||||
`05eacb3`. Added `VoiceServerOptions` (name/guests/capacity, handshake deadline,
|
`05eacb3`. Added `VoiceServerOptions` (name/guests/capacity, handshake deadline,
|
||||||
idle timeout and sweep interval), preserving the previous constructor overload.
|
idle timeout and sweep interval), preserving the previous constructor overload.
|
||||||
|
|||||||
+35
-4
@@ -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
|
Success returns permissions, then a cloned snapshot; peers receive joined/updated/left
|
||||||
events. Server-authoritative text replaces supplied sender ids/timestamps, limits
|
events. Server-authoritative text replaces supplied sender ids/timestamps, limits
|
||||||
bodies to 4096 UTF-8 bytes, and acknowledges valid or rejected routing. Channel text
|
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
|
requires membership; private text echoes to sender and recipient. Protected joins enforce
|
||||||
joins and all admin/moderation handlers are pending.
|
the supplied password and capacity; `LeaveChannel` returns to Lobby. Passwords use the
|
||||||
|
native salted, keyed BLAKE2b-256 `salt_hex:hash_hex` format, verified in both directions.
|
||||||
|
|
||||||
|
Channel create/edit/delete persist before broadcasting events. Administrators can manage
|
||||||
|
all channels; `CanCreateTempChannel` permits creation of temporary channels only. Edit with
|
||||||
|
an empty password preserves the existing hash, matching native behavior; password removal
|
||||||
|
has no v2 request representation. Lobby cannot be deleted, protected or nested. Missing
|
||||||
|
parents, tree cycles and deletion of parents with children fail without mutation. Deletion
|
||||||
|
moves members to Lobby (even if full), clearing their streams. Edits stop existing streams
|
||||||
|
so clients must negotiate the updated audio configuration. Channel names/topics/passwords
|
||||||
|
are limited to 128/4096/1024 UTF-8 bytes. Audio requires Opus, 48 kHz, mono/stereo,
|
||||||
|
500–512000 bps, integral 5/10/20/40/60 ms frames and valid application/loss/complexity.
|
||||||
|
Database v2 has no DRED column; CRUD rejects DRED rather than silently losing it on restart.
|
||||||
|
|
||||||
|
Session permissions gate kick/ban/move/mute and account operations. Only administrators
|
||||||
|
can grant permissions; account-administration permission cannot grant administrator status.
|
||||||
|
These two permission restrictions are stricter than the C++ oracle. Moves bypass channel
|
||||||
|
passwords but respect capacity and clear streams. Server mute/deafen immediately updates
|
||||||
|
encrypted routing. Kick/ban retire routing before closure and emit one LEFT with the reason.
|
||||||
|
Account bans persist by username; guest bans persist by address because nicknames are not
|
||||||
|
identities. Ban wire expiry is Unix milliseconds, converted to database seconds rounded up;
|
||||||
|
zero means permanent. This fixes the native handler's millisecond/second mismatch.
|
||||||
|
Existing sessions on the same address/account are not swept by a target-user ban.
|
||||||
|
|
||||||
|
Create/reset/delete/list accounts require administrator or `CanAdminAccounts`. New accounts
|
||||||
|
are non-admin. Bounded Argon2 work runs outside the server state lock; authority is checked
|
||||||
|
when accepting the operation, and cancellation is checked before password writes. Reset
|
||||||
|
and deletion affect future authentication; existing sessions retain their permissions.
|
||||||
|
Lists omit password hashes and return millisecond timestamps. Oversized lists fail instead
|
||||||
|
of truncating or exceeding the 64 KiB frame limit. Privileged responses echo request ids;
|
||||||
|
generic codes are 6 for permission denied and 3 for invalid/missing/duplicate input.
|
||||||
|
|
||||||
`VoiceServer.MediaEndPoint` exposes the bound UDP endpoint; UDP uses the same address
|
`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
|
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.
|
and rejects unknown revisions. Opening an existing channel table does not reseed it.
|
||||||
Account creation/authentication uses parameterized SQL; two password workers bound
|
Account creation/authentication uses parameterized SQL; two password workers bound
|
||||||
per-store Argon2 work. Failed authentication leaves `last_login` unchanged. Dispose
|
per-store Argon2 work. Failed authentication leaves `last_login` unchanged. Dispose
|
||||||
after its operations finish. Account provisioning currently uses this API or the
|
after its operations finish. `ResetPasswordAsync`, `DeleteAccount` and `ListAccounts`
|
||||||
existing native administration path; there is no automatic bootstrap account.
|
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
|
`PasswordHasher` uses strict UTF-8 without normalization and libsodium-compatible
|
||||||
Argon2id v19 PHC strings: 16-byte salt, 32-byte output, new-hash parameters
|
Argon2id v19 PHC strings: 16-byte salt, 32-byte output, new-hash parameters
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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.
|
1. `VoiceCat.Server`: accept loop, `ConnSession` protocol handling, session registry.
|
||||||
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
|
2. `Db` on `Microsoft.Data.Sqlite` — same schema. **Resolve the Argon2id hash-compat
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ int main(int argc, char **argv) {
|
|||||||
voicecat::server::Database database(argv[2]);
|
voicecat::server::Database database(argv[2]);
|
||||||
std::string error;
|
std::string error;
|
||||||
if (!database.open(error)) return 1;
|
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 (std::string(argv[1]) == "create") {
|
||||||
if (!database.create_account("legacy", "legacy password", true, error)) return 1;
|
if (!database.create_account("legacy", "legacy password", true, error)) return 1;
|
||||||
voicecat::server::ChannelRecord lobby;
|
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 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();
|
static AccountStore() => SQLitePCL.Batteries_V2.Init();
|
||||||
private readonly string connectionString;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,13 +10,13 @@ using Voicecat.V1;
|
|||||||
|
|
||||||
namespace VoiceCat.Server;
|
namespace VoiceCat.Server;
|
||||||
|
|
||||||
public sealed class VoiceServer : IAsyncDisposable
|
public sealed partial class VoiceServer : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly Socket listener;
|
private readonly Socket listener;
|
||||||
private readonly MediaRelay media;
|
private readonly MediaRelay media;
|
||||||
private readonly ServerCredentials credentials;
|
private readonly ServerCredentials credentials;
|
||||||
private readonly AccountStore accounts;
|
private readonly AccountStore accounts;
|
||||||
private readonly IReadOnlyList<Voicecat.V1.Channel> channels;
|
private readonly List<Voicecat.V1.Channel> channels;
|
||||||
private readonly bool allowGuests;
|
private readonly bool allowGuests;
|
||||||
private readonly string name;
|
private readonly string name;
|
||||||
private readonly VoiceServerOptions options;
|
private readonly VoiceServerOptions options;
|
||||||
@@ -51,7 +51,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
accounts = new AccountStore(Path.Combine(directory, "voicecat.db"));
|
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 = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||||
listener.Bind(endpoint);
|
listener.Bind(endpoint);
|
||||||
listener.Listen(options.MaximumConnections);
|
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))
|
await foreach (Envelope envelope in session.Connection.ReadAsync(shutdown.Token).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
if (session.Closing) break;
|
||||||
session.Activity.Touch();
|
session.Activity.Touch();
|
||||||
if (envelope.Ping is not null)
|
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.TextMessage: RelayText(session, envelope.TextMessage); break;
|
||||||
case Envelope.BodyOneofCase.Subscribe: SendSnapshot(session); 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.SubscribeVoice: SubscribeVoice(session, envelope.RequestId, true); break;
|
||||||
case Envelope.BodyOneofCase.UnsubscribeVoice: SubscribeVoice(session, envelope.RequestId, false); break;
|
case Envelope.BodyOneofCase.UnsubscribeVoice: SubscribeVoice(session, envelope.RequestId, false); break;
|
||||||
case Envelope.BodyOneofCase.StreamAnnounce: AnnounceStream(session, envelope.RequestId, envelope.StreamAnnounce); 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);
|
sessions.Remove(session.Id);
|
||||||
if (session.User is null) session.Media?.Dispose();
|
if (session.User is null) session.Media?.Dispose();
|
||||||
else PublishMedia();
|
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);
|
await session.Connection.DisposeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
@@ -208,6 +222,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
|||||||
session.Connection.CancellationToken.ThrowIfCancellationRequested();
|
session.Connection.CancellationToken.ThrowIfCancellationRequested();
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
|
if (session.Closing) return;
|
||||||
var lobby = channels.FirstOrDefault(channel => channel.Id == 1);
|
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)
|
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);
|
user.Id = checked(++nextUser);
|
||||||
session.User = user;
|
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()
|
session.Connection.TrySend(new() { RequestId = requestId, AuthResult = new()
|
||||||
{
|
{
|
||||||
Ok = true, SessionId = session.Id, Self = user.Clone(), UdpToken = ByteString.CopyFrom(session.Media!.Token),
|
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();
|
PublishMedia();
|
||||||
Broadcast(new() { UserEvent = new() { Kind = UserEvent.Types.Kind.Joined, User = user.Clone() } }, session.Id);
|
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)
|
lock (gate)
|
||||||
{
|
{
|
||||||
var channel = channels.FirstOrDefault(candidate => candidate.Id == channelId);
|
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." } });
|
session.Connection.TrySend(new() { RequestId = requestId, JoinChannelResult = new() { Error = "Channel unavailable." } });
|
||||||
return;
|
return;
|
||||||
@@ -282,7 +298,7 @@ public sealed class VoiceServer : IAsyncDisposable
|
|||||||
|
|
||||||
private void PublishMedia()
|
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.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());
|
peer.User.Streams.Select(stream => stream.Ssrc).ToArray())).ToArray());
|
||||||
}
|
}
|
||||||
@@ -380,8 +396,10 @@ public sealed class VoiceServer : IAsyncDisposable
|
|||||||
public string Address { get; } = address;
|
public string Address { get; } = address;
|
||||||
public SessionActivity Activity { get; } = activity;
|
public SessionActivity Activity { get; } = activity;
|
||||||
public bool Closing { get; set; }
|
public bool Closing { get; set; }
|
||||||
|
public string DepartureReason { get; set; } = "";
|
||||||
public bool HelloReceived { get; set; }
|
public bool HelloReceived { get; set; }
|
||||||
public User? User { get; set; }
|
public User? User { get; set; }
|
||||||
|
public Permissions Permissions { get; set; } = new();
|
||||||
public MediaPeer? Media { get; set; }
|
public MediaPeer? Media { get; set; }
|
||||||
public uint NextStream;
|
public uint NextStream;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,28 @@ public sealed class AccountStoreTests
|
|||||||
finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } }
|
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
|
private sealed class NativeDatabaseFactAttribute : FactAttribute
|
||||||
{
|
{
|
||||||
public NativeDatabaseFactAttribute()
|
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 } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ static bool connect_guest(vc_client*& client, const char* name, const char* labe
|
|||||||
if (!client) return false;
|
if (!client) return false;
|
||||||
ev.client = client;
|
ev.client = client;
|
||||||
ev.label = label;
|
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_connect(client, "127.0.0.1", port) != VC_OK) return false;
|
||||||
if (vc_authenticate_guest(client, name) != 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;
|
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(clientA, "SrA", "sr-a", port, evA));
|
||||||
CHECK(connect_guest(clientB, "SrB", "sr-b", port, evB));
|
CHECK(connect_guest(clientB, "SrB", "sr-b", port, evB));
|
||||||
int64_t energy = -1;
|
int64_t energy = -1;
|
||||||
|
SinkData sink; // Outlives both clients and their final audio callbacks.
|
||||||
if (!clientA || !clientB) goto cleanup;
|
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);
|
CHECK(vc_move_user(admin, b_uid, channel_id) == VC_OK);
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||||
|
|
||||||
SinkData sink;
|
|
||||||
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
CHECK(vc_set_pcm_sink(clientB, pcm_sink, &sink) == VC_OK);
|
||||||
|
|
||||||
vc_stream_desc desc{};
|
vc_stream_desc desc{};
|
||||||
desc.kind = VC_STREAM_MIC;
|
desc.kind = VC_STREAM_MIC;
|
||||||
|
desc.external_feed = 1; // Measure only the injected tone, never the host microphone.
|
||||||
uint32_t a_sid = 0;
|
uint32_t a_sid = 0;
|
||||||
CHECK(vc_stream_start(clientA, &desc, &a_sid) == VC_OK);
|
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));
|
CHECK(sink_wait(sink, 5000));
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
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",
|
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));
|
tag, expect_sr, sink.call_count.load(), static_cast<long long>(energy));
|
||||||
CHECK(sink.call_count.load() > 0);
|
CHECK(sink.call_count.load() > 0);
|
||||||
|
|||||||
@@ -375,6 +375,14 @@ int main(int argc, char** argv) {
|
|||||||
bool do_delete_channel = false;
|
bool do_delete_channel = false;
|
||||||
uint32_t delete_channel_id = 0;
|
uint32_t delete_channel_id = 0;
|
||||||
vc_channel_info channel_info{};
|
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
|
// Account management
|
||||||
bool do_create_account = false;
|
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 == "--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 == "--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 == "--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-name") { new_channel_name = next(); channel_info.name = new_channel_name.c_str(); }
|
||||||
else if (a == "--new-channel-topic") channel_info.topic = next().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-parent") { if (!parse_u32(next().c_str(), &channel_info.parent_id, "--new-channel-parent")) return 1; }
|
||||||
else if (a == "--new-channel-password") {
|
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;
|
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; }
|
else if (a == "--new-channel-max-users") { if (!parse_u32(next().c_str(), &channel_info.max_users, "--new-channel-max-users")) return 1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user