M5: moderation, permissions, channel CRUD, in-app account management

- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).

- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.

- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).

- vccli flags for all M5 operations plus --username/--password auth.

- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.

- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
This commit is contained in:
2026-06-17 15:08:05 +02:00
parent a2f159e971
commit 3990f63f0f
23 changed files with 3281 additions and 97 deletions

View File

@@ -10,6 +10,18 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **In progress:** **M5 — moderation & admin** (2026-06-17). Server-side and C ABI are
implemented and tested: permissions, kick/ban/move/server-mute, channel CRUD, in-app account
management. Four new tests pass: `test_m5_permissions`, `test_m5_kick_ban_move_mute`,
`test_m5_admin_accounts`, `test_m5_channel_crud`. `vccli` now exposes all M5 operations via
CLI flags (`--kick`, `--ban`, `--move`, `--server-mute`/`-unmute`/`-deafen`/`-undeafen`,
`--set-permission`, `--create-channel`, `--edit-channel`, `--delete-channel`,
`--create-account`, `--reset-password`, `--delete-account`, `--list-accounts`) plus
`--username`/`--password` for account auth and `--self-mute`/`--self-deafen`. Docs updated:
`docs/protocol.md` (envelope tags for `ServerMuteRequest`/`ListAccountsResult`, `User.server_deafened`,
`GenericResult` usage), `docs/security.md` (BLAKE2b channel passwords, `bans` schema).
`ctest --preset m1-dev`**18/18 green**. Still to do: DRED/audio-quality polish and Windows
admin/moderation UI.
- **Done:** **Fixed a *second* silent-playback bug — the playout clock free-ran and drifted off - **Done:** **Fixed a *second* silent-playback bug — the playout clock free-ran and drifted off
the stream** (2026-06-17, reported live: both `vccli` and the Windows client showed `talking=1/0` the stream** (2026-06-17, reported live: both `vccli` and the Windows client showed `talking=1/0`
correctly on VAD/PTT, mic + screen-share were recognized by peers, but nothing was audible). correctly on VAD/PTT, mic + screen-share were recognized by peers, but nothing was audible).
@@ -92,8 +104,8 @@ up instantly. Newest status at the top.
- [x] **M1 — Control plane** ✓ complete (2026-06-15) - [x] **M1 — Control plane** ✓ complete (2026-06-15)
- [x] **M2 — Voice, single stream** ✓ complete (2026-06-16) - [x] **M2 — Voice, single stream** ✓ complete (2026-06-16)
- [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16) - [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16)
- [~] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS/iOS Swift pending - [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS/iOS Swift pending
- [ ] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …) - [~] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …)
--- ---
@@ -450,6 +462,50 @@ DLLs remain — verified via `objdump -p`).
--- ---
## M5 — Moderation, polish, and beyond [~] (in progress 2026-06-17)
**Exit criterion:** four ABI-level tests green (`test_m5_permissions`,
`test_m5_kick_ban_move_mute`, `test_m5_admin_accounts`, `test_m5_channel_crud`);
`vccli` can drive all moderation/admin/channel operations against a live server.
- [x] **Server-side moderation & permissions:**
- `server/src/session_registry.h/.cpp` — per-session `Permissions`, permission helpers
(`can_kick`, `can_ban`, etc.), kick/ban/move/server-mute, channel CRUD, DB-backed channel
tree load/save, in-memory channel state.
- `server/src/conn_session.cpp` — M5 dispatch handlers, permission checks, channel-password
+ `max_users` enforcement, `UserEvent::UPDATED` broadcast on join/leave.
- `core/proto/voicecat.proto` — `ServerMuteRequest`, `UserEvent.reason`, `User.server_deafened`,
`ListAccountsResult`, `AccountEntry`.
- [x] **C ABI / client-side:**
- `core/include/voicecat.h` — `vc_permissions`, `vc_channel_info`, `vc_kick_user`,
`vc_ban_user`, `vc_set_permission`, `vc_set_server_mute`, `vc_move_user`,
`vc_create_channel`, `vc_edit_channel`, `vc_delete_channel`, `vc_create_account`,
`vc_reset_password`, `vc_delete_account`, `vc_list_accounts`, `vc_get_permissions`;
new events `VC_EVENT_GENERIC_RESULT` and `VC_EVENT_ACCOUNT_LIST`.
- `core/src/voicecat.cpp`, `core/src/core/client.h/.cpp` — implementations + server-mute/deafen
gating on the client.
- [x] **Database:** `server/src/db.h/.cpp` schema v2 (`channels`, `bans`), Argon2id accounts,
BLAKE2b channel passwords, migrations.
- [x] **Tests:** four new M5 tests registered in `tests/CMakeLists.txt`:
- `test_m5_permissions` — grant/revoke permissions, verify enforcement.
- `test_m5_kick_ban_move_mute` — kick, ban, move, server-mute/deafen.
- `test_m5_admin_accounts` — create/reset/delete/list accounts.
- `test_m5_channel_crud` — create/edit/delete channels, password + max_users enforcement.
- [x] **vccli** (`tools/vccli/src/main.cpp`) — all M5 operations exposed via flags; account auth
via `--username`/`--password`; async `VC_EVENT_GENERIC_RESULT`/`VC_EVENT_ACCOUNT_LIST` handling.
- [x] **Docs** kept in sync: `docs/protocol.md`, `docs/security.md`, `PROGRESS.md`.
**Key bug fixed:** `test_m5_channel_crud` failed because `SessionRegistry::create_channel`
broadcast `ChannelEvent::CREATED` from a moved-from `entry.proto` after
`channels_[id] = std::move(entry)`. Fixed by building the event before moving into the map.
**Still to do:**
- DRED/audio-quality polish.
- Windows admin/moderation UI in the WinForms client.
- macOS/iOS Swift client (carried from M4).
---
## Decisions log ## Decisions log
All architecture/scope decisions are settled and recorded in All architecture/scope decisions are settled and recorded in

View File

@@ -127,7 +127,7 @@ typedef enum vc_event_type {
VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */ VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */
VC_EVENT_ERROR = 10, /* result, text */ VC_EVENT_ERROR = 10, /* result, text */
VC_EVENT_DISCONNECTED = 11, /* result, text = reason */ VC_EVENT_DISCONNECTED = 11, /* result, text = reason */
/* M4 additions — appended, not inserted, to keep existing values stable. */ /* M4 additions — appended, not inserted, to keep existing enum values stable. */
VC_EVENT_JOIN_RESULT = 12, /* result (VC_OK/VC_ERR_*), channel_id, text = error on VC_EVENT_JOIN_RESULT = 12, /* result (VC_OK/VC_ERR_*), channel_id, text = error on
failure. Reply to vc_join_channel(). */ failure. Reply to vc_join_channel(). */
VC_EVENT_SERVER_IDENTITY = 13, /* u32a = vc_tofu_status, text = hex-encoded TLS leaf-cert VC_EVENT_SERVER_IDENTITY = 13, /* u32a = vc_tofu_status, text = hex-encoded TLS leaf-cert
@@ -136,6 +136,13 @@ typedef enum vc_event_type {
attempt, right after the TLS handshake succeeds. The attempt, right after the TLS handshake succeeds. The
connection is held open until vc_confirm_server_identity() connection is held open until vc_confirm_server_identity()
is called. */ is called. */
/* M5 additions — appended, not inserted. */
VC_EVENT_GENERIC_RESULT = 14, /* result, u32a = server error code, text = message. Reply
to vc_kick_user/vc_ban_user/vc_set_permission/
vc_move_user/vc_create_channel/vc_edit_channel/
vc_delete_channel/vc_create_account/vc_reset_password/
vc_delete_account. */
VC_EVENT_ACCOUNT_LIST = 15, /* Reply to vc_list_accounts. */
} vc_event_type; } vc_event_type;
/* TOFU server-identity classification (M4) — see VC_EVENT_SERVER_IDENTITY and /* TOFU server-identity classification (M4) — see VC_EVENT_SERVER_IDENTITY and
@@ -216,6 +223,30 @@ typedef struct vc_audio_config {
uint32_t complexity; /* 0..10 */ uint32_t complexity; /* 0..10 */
} vc_audio_config; } vc_audio_config;
/* M5: permission bitset (mirrors protocol Permissions). */
typedef struct vc_permissions {
int can_create_temp_channel; /* bool */
int can_kick; /* bool */
int can_ban; /* bool */
int can_move_users; /* bool */
int can_admin_accounts; /* bool */
int is_admin; /* bool */
} vc_permissions;
/* M5: channel creation/edition descriptor. */
typedef struct vc_channel_info {
uint32_t id; /* 0 = new channel for create */
uint32_t parent_id; /* 0 = root */
const char* name;
const char* topic;
int password_protected; /* bool */
const char* password; /* nullable; ignored if password_protected == 0 */
uint32_t max_users; /* 0 = unlimited */
uint32_t sort_order;
/* Audio config — 0/NULL fields use server defaults. */
vc_audio_config audio;
} vc_channel_info;
typedef struct vc_device { typedef struct vc_device {
const char* id; const char* id;
const char* name; const char* name;
@@ -370,6 +401,32 @@ VC_API vc_result vc_confirm_server_identity(vc_client* c, int accept /* bool */)
VC_API vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap, VC_API vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap,
size_t* out_len); size_t* out_len);
/* ── M5: Moderation & admin ─────────────────────────────────────────────────
* All calls are async; the result arrives as VC_EVENT_GENERIC_RESULT (or
* VC_EVENT_ACCOUNT_LIST for vc_list_accounts). They require VC_STATE_CONNECTED and,
* on the server side, the appropriate permission. */
VC_API vc_result vc_kick_user(vc_client* c, uint32_t user_id, const char* reason);
VC_API vc_result vc_ban_user(vc_client* c, uint32_t user_id, const char* reason,
uint64_t expires_unix_ms);
VC_API vc_result vc_set_permission(vc_client* c, uint32_t user_id,
const vc_permissions* perms);
VC_API vc_result vc_set_server_mute(vc_client* c, uint32_t user_id, int muted, int deafened);
VC_API vc_result vc_move_user(vc_client* c, uint32_t user_id, uint32_t channel_id);
VC_API vc_result vc_create_channel(vc_client* c, const vc_channel_info* info);
VC_API vc_result vc_edit_channel(vc_client* c, const vc_channel_info* info);
VC_API vc_result vc_delete_channel(vc_client* c, uint32_t channel_id);
VC_API vc_result vc_create_account(vc_client* c, const char* username, const char* password);
VC_API vc_result vc_reset_password(vc_client* c, const char* username,
const char* new_password);
VC_API vc_result vc_delete_account(vc_client* c, const char* username);
VC_API vc_result vc_list_accounts(vc_client* c);
/* Pull the caller's own permissions (from the last AuthResult). */
VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out);
#if defined(__cplusplus) #if defined(__cplusplus)
} /* extern "C" */ } /* extern "C" */
#endif #endif

View File

@@ -54,12 +54,14 @@ message Envelope {
KickRequest kick = 60; KickRequest kick = 60;
BanRequest ban = 61; BanRequest ban = 61;
SetPermissionRequest set_permission = 62; SetPermissionRequest set_permission = 62;
ServerMuteRequest server_mute = 63;
// Admin account management — privileged; accounts are admin-provisioned (7079) // Admin account management — privileged; accounts are admin-provisioned (7079)
CreateAccountRequest create_account = 70; CreateAccountRequest create_account = 70;
ResetPasswordRequest reset_password = 71; ResetPasswordRequest reset_password = 71;
DeleteAccountRequest delete_account = 72; DeleteAccountRequest delete_account = 72;
ListAccountsRequest list_accounts = 73; ListAccountsRequest list_accounts = 73;
ListAccountsResult list_accounts_result = 74;
// Future families: file transfer = 100109. Extension escape hatch = 200+. // Future families: file transfer = 100109. Extension escape hatch = 200+.
Extension extension = 200; Extension extension = 200;
@@ -116,6 +118,7 @@ message User {
bool self_deafened = 6; bool self_deafened = 6;
bool server_muted = 7; bool server_muted = 7;
repeated StreamInfo streams = 8; repeated StreamInfo streams = 8;
bool server_deafened = 9; // M5: server-imposed deafen
} }
message Permissions { message Permissions {
@@ -186,6 +189,7 @@ message UserEvent {
Kind kind = 1; Kind kind = 1;
User user = 2; User user = 2;
uint32 left_id = 3; uint32 left_id = 3;
string reason = 4; // M5: kick/ban reason for LEFT events
} }
message SubscribeRequest { repeated uint32 channel_ids = 1; bool presence = 2; } message SubscribeRequest { repeated uint32 channel_ids = 1; bool presence = 2; }
@@ -227,12 +231,15 @@ message TypingIndicator { TextScope scope = 1; uint32 target_id = 2; uint32 use
message KickRequest { uint32 user_id = 1; string reason = 2; } message KickRequest { uint32 user_id = 1; string reason = 2; }
message BanRequest { uint32 user_id = 1; string reason = 2; uint64 expires_unix_ms = 3; } message BanRequest { uint32 user_id = 1; string reason = 2; uint64 expires_unix_ms = 3; }
message SetPermissionRequest { uint32 user_id = 1; Permissions permissions = 2; } message SetPermissionRequest { uint32 user_id = 1; Permissions permissions = 2; }
message ServerMuteRequest { uint32 user_id = 1; bool muted = 2; bool deafened = 3; }
// ── Admin account management (privileged) ──────────────────────────────────────── // ── Admin account management (privileged) ────────────────────────────────────────
message CreateAccountRequest { string username = 1; string password = 2; } message CreateAccountRequest { string username = 1; string password = 2; }
message ResetPasswordRequest { string username = 1; string new_password = 2; } message ResetPasswordRequest { string username = 1; string new_password = 2; }
message DeleteAccountRequest { string username = 1; } message DeleteAccountRequest { string username = 1; }
message ListAccountsRequest {} message ListAccountsRequest {}
message AccountEntry { string username = 1; bool is_admin = 2; uint64 created_at_unix_ms = 3; uint64 last_login_unix_ms = 4; }
message ListAccountsResult { repeated AccountEntry accounts = 1; }
// ── Extension escape hatch ─────────────────────────────────────────────────────── // ── Extension escape hatch ───────────────────────────────────────────────────────
message Extension { string ns = 1; bytes payload = 2; } message Extension { string ns = 1; bytes payload = 2; }

View File

@@ -309,7 +309,7 @@ void vc_client::run_io(std::string host, uint16_t port) {
io_fd_.store(-1); io_fd_.store(-1);
} }
if (!io_stop_.load()) emit_disconnected(VC_OK, nullptr); if (!io_stop_.load()) emit_disconnected(VC_ERR_IO, "connection closed");
cleanup: cleanup:
#ifdef _WIN32 #ifdef _WIN32
@@ -415,6 +415,21 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
break; break;
case voicecat::v1::Envelope::kPong: case voicecat::v1::Envelope::kPong:
break; // ignore keepalive responses break; // ignore keepalive responses
case voicecat::v1::Envelope::kGenericResult: {
vc_event ev{};
ev.type = VC_EVENT_GENERIC_RESULT;
ev.result = env.generic_result().ok() ? VC_OK : VC_ERR_PERMISSION_DENIED;
ev.u32a = env.generic_result().code();
ev.text = env.generic_result().message().c_str();
emit(ev);
break;
}
case voicecat::v1::Envelope::kListAccountsResult: {
vc_event ev{};
ev.type = VC_EVENT_ACCOUNT_LIST;
emit(ev);
break;
}
default: default:
break; break;
} }
@@ -468,6 +483,15 @@ void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) {
ev.user_id = self_user_id_; ev.user_id = self_user_id_;
set_state(VC_STATE_CONNECTED); set_state(VC_STATE_CONNECTED);
// Store own permissions.
const auto& perms = msg.permissions();
own_permissions_.can_create_temp_channel = perms.can_create_temp_channel() ? 1 : 0;
own_permissions_.can_kick = perms.can_kick() ? 1 : 0;
own_permissions_.can_ban = perms.can_ban() ? 1 : 0;
own_permissions_.can_move_users = perms.can_move_users() ? 1 : 0;
own_permissions_.can_admin_accounts = perms.can_admin_accounts() ? 1 : 0;
own_permissions_.is_admin = perms.is_admin() ? 1 : 0;
const std::string& tok = msg.udp_token(); const std::string& tok = msg.udp_token();
if (tok.size() == udp_token_.size()) { if (tok.size() == udp_token_.size()) {
std::memcpy(udp_token_.data(), tok.data(), udp_token_.size()); std::memcpy(udp_token_.data(), tok.data(), udp_token_.size());
@@ -549,6 +573,18 @@ void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
case voicecat::v1::UserEvent::UPDATED: case voicecat::v1::UserEvent::UPDATED:
ev.type = VC_EVENT_USER_UPDATED; ev.type = VC_EVENT_USER_UPDATED;
emit(ev); emit(ev);
// M5: if this is an update to our own user, reflect server-mute/deafen locally.
if (user.id() == self_user_id_) {
server_muted_.store(user.server_muted(), std::memory_order_release);
server_deafened_.store(user.server_deafened(), std::memory_order_release);
std::lock_guard lk(remote_streams_mu_);
bool muted = self_deafened_.load(std::memory_order_acquire) ||
server_deafened_.load(std::memory_order_acquire);
for (auto& [ssrc, info] : remote_streams_) {
(void)info;
audio_engine_.set_stream_mute(ssrc, muted);
}
}
sync_remote_streams(user); sync_remote_streams(user);
break; break;
default: default:
@@ -776,6 +812,36 @@ voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::Au
p.application = static_cast<voicecat::codec::OpusApplication>(a.application()); p.application = static_cast<voicecat::codec::OpusApplication>(a.application());
return p; return p;
} }
voicecat::v1::AudioConfig audio_config_from_vc(const vc_audio_config& c) {
voicecat::v1::AudioConfig a;
a.set_codec(c.codec);
a.set_mode(c.mode == 1 ? voicecat::v1::MODE_STEREO : voicecat::v1::MODE_MONO);
a.set_sample_rate(c.sample_rate);
a.set_bitrate_bps(c.bitrate_bps);
a.set_frame_ms(c.frame_ms);
a.set_application(static_cast<voicecat::v1::OpusApplication>(c.application));
a.set_fec(c.fec != 0);
a.set_expected_packet_loss(c.expected_packet_loss);
a.set_dtx(c.dtx != 0);
a.set_complexity(c.complexity);
return a;
}
voicecat::v1::Channel channel_from_vc(const vc_channel_info& c) {
voicecat::v1::Channel ch;
ch.set_id(c.id);
ch.set_parent_id(c.parent_id);
ch.set_name(c.name ? c.name : "");
ch.set_topic(c.topic ? c.topic : "");
ch.set_password_protected(c.password_protected != 0);
ch.set_max_users(c.max_users);
ch.set_type(voicecat::v1::CHANNEL_PERMANENT);
*ch.mutable_audio() = audio_config_from_vc(c.audio);
ch.set_order(static_cast<int32_t>(c.sort_order));
return ch;
}
} // namespace } // namespace
void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples) { void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples) {
@@ -784,8 +850,10 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples) {
if (it == local_streams_.end() || !it->second.active.load(std::memory_order_acquire)) return; if (it == local_streams_.end() || !it->second.active.load(std::memory_order_acquire)) return;
// "Mic muted" only gates the MIC stream — a concurrently-running SCREEN_AUDIO share keeps // "Mic muted" only gates the MIC stream — a concurrently-running SCREEN_AUDIO share keeps
// playing while the user's mic is muted (docs §M3 scope decision). // playing while the user's mic is muted (docs §M3 scope decision).
// M5: server-mute is also a hard gate on MIC transmission.
if (kind == static_cast<int>(VC_STREAM_MIC) && if (kind == static_cast<int>(VC_STREAM_MIC) &&
self_mic_muted_.load(std::memory_order_acquire)) return; (self_mic_muted_.load(std::memory_order_acquire) ||
server_muted_.load(std::memory_order_acquire))) return;
// Send-side input gate (docs/voice.md §11) — MIC only. SCREEN_AUDIO/AUX_DEVICE always // Send-side input gate (docs/voice.md §11) — MIC only. SCREEN_AUDIO/AUX_DEVICE always
// bypass this: gating a screen-share on the user's own voice activity would silently drop // bypass this: gating a screen-share on the user's own voice activity would silently drop
@@ -888,7 +956,9 @@ void vc_client::sync_remote_streams(const voicecat::v1::User& user) {
voicecat::codec::OpusParams p = opus_params_from_audio_config(si.audio()); voicecat::codec::OpusParams p = opus_params_from_audio_config(si.audio());
audio_engine_.init_recv_stream(ssrc, p); audio_engine_.init_recv_stream(ssrc, p);
audio_engine_.set_stream_mute(ssrc, self_deafened_.load(std::memory_order_acquire)); bool muted = self_deafened_.load(std::memory_order_acquire) ||
server_deafened_.load(std::memory_order_acquire);
audio_engine_.set_stream_mute(ssrc, muted);
newly_added.emplace_back(ssrc, si.stream_id()); newly_added.emplace_back(ssrc, si.stream_id());
} }
@@ -1111,9 +1181,10 @@ vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) {
self_deafened_.store(deafened, std::memory_order_release); self_deafened_.store(deafened, std::memory_order_release);
std::lock_guard lk(remote_streams_mu_); std::lock_guard lk(remote_streams_mu_);
bool muted = deafened || server_deafened_.load(std::memory_order_acquire);
for (auto& [ssrc, info] : remote_streams_) { for (auto& [ssrc, info] : remote_streams_) {
(void)info; (void)info;
audio_engine_.set_stream_mute(ssrc, deafened); audio_engine_.set_stream_mute(ssrc, muted);
} }
return VC_OK; return VC_OK;
} }
@@ -1306,6 +1377,154 @@ vc_result vc_client::get_server_identity_display(char* out_buf, size_t buf_cap,
return VC_OK; return VC_OK;
} }
// ── M5: Moderation & admin ───────────────────────────────────────────────────
vc_result vc_client::kick_user(uint32_t user_id, const char* reason) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* k = req.mutable_kick();
k->set_user_id(user_id);
k->set_reason(reason ? reason : "");
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::ban_user(uint32_t user_id, const char* reason, uint64_t expires_unix_ms) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* b = req.mutable_ban();
b->set_user_id(user_id);
b->set_reason(reason ? reason : "");
b->set_expires_unix_ms(expires_unix_ms);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::set_permission(uint32_t user_id, const vc_permissions* perms) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
if (!perms) return VC_ERR_INVALID_ARG;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* sp = req.mutable_set_permission();
sp->set_user_id(user_id);
auto* p = sp->mutable_permissions();
p->set_can_create_temp_channel(perms->can_create_temp_channel != 0);
p->set_can_kick(perms->can_kick != 0);
p->set_can_ban(perms->can_ban != 0);
p->set_can_move_users(perms->can_move_users != 0);
p->set_can_admin_accounts(perms->can_admin_accounts != 0);
p->set_is_admin(perms->is_admin != 0);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::set_server_mute(uint32_t user_id, bool muted, bool deafened) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* sm = req.mutable_server_mute();
sm->set_user_id(user_id);
sm->set_muted(muted);
sm->set_deafened(deafened);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::move_user(uint32_t user_id, uint32_t channel_id) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* m = req.mutable_move_user();
m->set_user_id(user_id);
m->set_channel_id(channel_id);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::create_channel(const vc_channel_info* info) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
if (!info || !info->name) return VC_ERR_INVALID_ARG;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* cc = req.mutable_create_channel();
*cc->mutable_channel() = channel_from_vc(*info);
if (info->password_protected && info->password) cc->set_password(info->password);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::edit_channel(const vc_channel_info* info) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
if (!info || !info->name) return VC_ERR_INVALID_ARG;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* ec = req.mutable_edit_channel();
*ec->mutable_channel() = channel_from_vc(*info);
if (info->password_protected && info->password) ec->set_password(info->password);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::delete_channel(uint32_t channel_id) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
req.mutable_delete_channel()->set_channel_id(channel_id);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::create_account(const char* username, const char* password) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
if (!username || !password) return VC_ERR_INVALID_ARG;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* ca = req.mutable_create_account();
ca->set_username(username);
ca->set_password(password);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::reset_password(const char* username, const char* new_password) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
if (!username || !new_password) return VC_ERR_INVALID_ARG;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
auto* rp = req.mutable_reset_password();
rp->set_username(username);
rp->set_new_password(new_password);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::delete_account(const char* username) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
if (!username) return VC_ERR_INVALID_ARG;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
req.mutable_delete_account()->set_username(username);
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::list_accounts() {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
req.mutable_list_accounts();
queue_envelope(req);
return VC_OK;
}
vc_result vc_client::get_permissions(vc_permissions* out) {
if (!out) return VC_ERR_INVALID_ARG;
*out = own_permissions_;
return state_net_.load(std::memory_order_acquire) == VC_STATE_CONNECTED ? VC_OK : VC_ERR_NOT_CONNECTED;
}
void vc_client::run_talk_timer() { void vc_client::run_talk_timer() {
while (!talk_timer_stop_.load(std::memory_order_acquire)) { while (!talk_timer_stop_.load(std::memory_order_acquire)) {
// Remote streams: ask the engine for edge-triggered transitions, map ssrc -> (user, // Remote streams: ask the engine for edge-triggered transitions, map ssrc -> (user,
@@ -1413,5 +1632,18 @@ vc_result vc_client::get_server_identity_display(char*, size_t, size_t* out_len)
if (out_len) *out_len = 0; if (out_len) *out_len = 0;
return VC_ERR_NOT_IMPLEMENTED; return VC_ERR_NOT_IMPLEMENTED;
} }
vc_result vc_client::kick_user(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::ban_user(uint32_t, const char*, uint64_t) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_permission(uint32_t, const vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_server_mute(uint32_t, bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::move_user(uint32_t, uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::create_channel(const vc_channel_info*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::edit_channel(const vc_channel_info*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::delete_channel(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::create_account(const char*, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::reset_password(const char*, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::delete_account(const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::list_accounts() { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::get_permissions(vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; }
#endif // VOICECAT_HAS_NET #endif // VOICECAT_HAS_NET

View File

@@ -77,6 +77,21 @@ struct vc_client {
// TEST-ONLY (see voicecat.h) — inject synthetic PCM into a local stream's encode pipeline. // TEST-ONLY (see voicecat.h) — inject synthetic PCM into a local stream's encode pipeline.
vc_result test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples); vc_result test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples);
// M5: moderation & admin.
vc_result kick_user(uint32_t user_id, const char* reason);
vc_result ban_user(uint32_t user_id, const char* reason, uint64_t expires_unix_ms);
vc_result set_permission(uint32_t user_id, const vc_permissions* perms);
vc_result set_server_mute(uint32_t user_id, bool muted, bool deafened);
vc_result move_user(uint32_t user_id, uint32_t channel_id);
vc_result create_channel(const vc_channel_info* info);
vc_result edit_channel(const vc_channel_info* info);
vc_result delete_channel(uint32_t channel_id);
vc_result create_account(const char* username, const char* password);
vc_result reset_password(const char* username, const char* new_password);
vc_result delete_account(const char* username);
vc_result list_accounts();
vc_result get_permissions(vc_permissions* out);
vc_connection_state state() const { vc_connection_state state() const {
#ifdef VOICECAT_HAS_NET #ifdef VOICECAT_HAS_NET
return state_net_.load(std::memory_order_acquire); return state_net_.load(std::memory_order_acquire);
@@ -202,6 +217,11 @@ struct vc_client {
std::atomic<bool> self_mic_muted_{false}; std::atomic<bool> self_mic_muted_{false};
std::atomic<bool> self_deafened_{false}; std::atomic<bool> self_deafened_{false};
std::atomic<bool> server_muted_{false};
std::atomic<bool> server_deafened_{false};
// M5: permissions from last AuthResult.
vc_permissions own_permissions_{};
// Follow-up to M3: send-side input gate (docs/voice.md §11). MIC-only — SCREEN_AUDIO/ // Follow-up to M3: send-side input gate (docs/voice.md §11). MIC-only — SCREEN_AUDIO/
// AUX_DEVICE are never gated (see PROGRESS.md for the rationale). Pure local state, no // AUX_DEVICE are never gated (see PROGRESS.md for the rationale). Pure local state, no

View File

@@ -70,6 +70,8 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap)
u.nickname = pb.nickname(); u.nickname = pb.nickname();
u.is_guest = pb.is_guest(); u.is_guest = pb.is_guest();
u.channel_id = pb.channel_id(); u.channel_id = pb.channel_id();
u.server_muted = pb.server_muted();
u.server_deafened = pb.server_deafened();
u.streams = copy_streams(pb.streams()); u.streams = copy_streams(pb.streams());
users_.push_back(std::move(u)); users_.push_back(std::move(u));
} }
@@ -85,6 +87,8 @@ void SessionModel::apply_user_event(const voicecat::v1::UserEvent& ev) {
u.nickname = pb.nickname(); u.nickname = pb.nickname();
u.is_guest = pb.is_guest(); u.is_guest = pb.is_guest();
u.channel_id = pb.channel_id(); u.channel_id = pb.channel_id();
u.server_muted = pb.server_muted();
u.server_deafened = pb.server_deafened();
u.streams = copy_streams(pb.streams()); u.streams = copy_streams(pb.streams());
auto it = std::find_if(users_.begin(), users_.end(), auto it = std::find_if(users_.begin(), users_.end(),

View File

@@ -51,6 +51,8 @@ struct User {
std::string nickname; std::string nickname;
bool is_guest{true}; bool is_guest{true};
uint32_t channel_id{0}; uint32_t channel_id{0};
bool server_muted{false};
bool server_deafened{false};
std::vector<Stream> streams; std::vector<Stream> streams;
}; };

View File

@@ -207,4 +207,70 @@ vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf
return c->get_server_identity_display(out_buf, buf_cap, out_len); return c->get_server_identity_display(out_buf, buf_cap, out_len);
} }
vc_result vc_kick_user(vc_client* c, uint32_t user_id, const char* reason) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->kick_user(user_id, reason);
}
vc_result vc_ban_user(vc_client* c, uint32_t user_id, const char* reason,
uint64_t expires_unix_ms) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->ban_user(user_id, reason, expires_unix_ms);
}
vc_result vc_set_permission(vc_client* c, uint32_t user_id, const vc_permissions* perms) {
if (c == nullptr || perms == nullptr) return VC_ERR_INVALID_ARG;
return c->set_permission(user_id, perms);
}
vc_result vc_set_server_mute(vc_client* c, uint32_t user_id, int muted, int deafened) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_server_mute(user_id, muted != 0, deafened != 0);
}
vc_result vc_move_user(vc_client* c, uint32_t user_id, uint32_t channel_id) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->move_user(user_id, channel_id);
}
vc_result vc_create_channel(vc_client* c, const vc_channel_info* info) {
if (c == nullptr || info == nullptr) return VC_ERR_INVALID_ARG;
return c->create_channel(info);
}
vc_result vc_edit_channel(vc_client* c, const vc_channel_info* info) {
if (c == nullptr || info == nullptr) return VC_ERR_INVALID_ARG;
return c->edit_channel(info);
}
vc_result vc_delete_channel(vc_client* c, uint32_t channel_id) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->delete_channel(channel_id);
}
vc_result vc_create_account(vc_client* c, const char* username, const char* password) {
if (c == nullptr || username == nullptr || password == nullptr) return VC_ERR_INVALID_ARG;
return c->create_account(username, password);
}
vc_result vc_reset_password(vc_client* c, const char* username, const char* new_password) {
if (c == nullptr || username == nullptr || new_password == nullptr) return VC_ERR_INVALID_ARG;
return c->reset_password(username, new_password);
}
vc_result vc_delete_account(vc_client* c, const char* username) {
if (c == nullptr || username == nullptr) return VC_ERR_INVALID_ARG;
return c->delete_account(username);
}
vc_result vc_list_accounts(vc_client* c) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->list_accounts();
}
vc_result vc_get_permissions(vc_client* c, vc_permissions* out) {
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
return c->get_permissions(out);
}
} // extern "C" } // extern "C"

View File

@@ -92,6 +92,7 @@ message Envelope {
KickRequest kick = 60; KickRequest kick = 60;
BanRequest ban = 61; BanRequest ban = 61;
SetPermissionRequest set_permission = 62; SetPermissionRequest set_permission = 62;
ServerMuteRequest server_mute = 63;
// ── Admin account management (privileged) ───────────── // ── Admin account management (privileged) ─────────────
// Accounts are admin-provisioned (no self-serve registration in v1). // Accounts are admin-provisioned (no self-serve registration in v1).
@@ -100,6 +101,7 @@ message Envelope {
ResetPasswordRequest reset_password = 71; ResetPasswordRequest reset_password = 71;
DeleteAccountRequest delete_account = 72; DeleteAccountRequest delete_account = 72;
ListAccountsRequest list_accounts = 73; ListAccountsRequest list_accounts = 73;
ListAccountsResult list_accounts_result = 74;
// ── Extension escape hatch ──────────────────────────── // ── Extension escape hatch ────────────────────────────
Extension extension = 200; // {string ns; bytes payload;} Extension extension = 200; // {string ns; bytes payload;}
@@ -224,6 +226,7 @@ message User {
bool self_deafened = 6; bool self_deafened = 6;
bool server_muted = 7; bool server_muted = 7;
repeated StreamInfo streams = 8; // active media streams this user publishes repeated StreamInfo streams = 8; // active media streams this user publishes
bool server_deafened = 9; // M5: server-imposed deafen
} }
message StreamInfo { message StreamInfo {
@@ -268,7 +271,8 @@ message TextMessage {
server→client events use `request_id = 0`. server→client events use `request_id = 0`.
- **`GenericResult { bool ok; uint32 code; string message; }`** is the default - **`GenericResult { bool ok; uint32 code; string message; }`** is the default
acknowledgement for operations without a richer reply (create/edit/delete channel, move acknowledgement for operations without a richer reply (create/edit/delete channel, move
user, etc.). Error `code`s are an enumerated, stable list. user, kick, ban, server-mute, set-permission, create/reset/delete account). Error `code`s
are an enumerated, stable list.
- Fatal conditions send **`Disconnect { code; reason }`** then close the TLS connection. - Fatal conditions send **`Disconnect { code; reason }`** then close the TLS connection.
## 7. Keepalive & timeouts ## 7. Keepalive & timeouts

View File

@@ -117,7 +117,7 @@ Binding works as:
hashed with **Argon2id** (via libsodium `crypto_pwhash`) using per-install-tuned memory/time hashed with **Argon2id** (via libsodium `crypto_pwhash`) using per-install-tuned memory/time
parameters; never stored or logged in plaintext. Verification runs on the worker pool (it's parameters; never stored or logged in plaintext. Verification runs on the worker pool (it's
deliberately slow) to avoid stalling the net thread. deliberately slow) to avoid stalling the net thread.
- **Channel passwords:** hashed at rest too; join attempts compare server-side. - **Channel passwords:** hashed at rest with **BLAKE2b** (libsodium `crypto_generichash`) plus a per-channel salt. BLAKE2b is used instead of Argon2id here because channel-password checks happen on the net thread during `JoinChannelRequest`; a slow hash would block real-time message processing. The password itself still crosses the wire only inside TLS 1.3.
- **Brute-force defense:** per-IP and per-account rate limiting on auth attempts with - **Brute-force defense:** per-IP and per-account rate limiting on auth attempts with
exponential backoff; configurable lockout. Generic `auth_request` failures return a exponential backoff; configurable lockout. Generic `auth_request` failures return a
non-enumerating error ("invalid credentials") to avoid username probing. non-enumerating error ("invalid credentials") to avoid username probing.
@@ -126,7 +126,7 @@ Binding works as:
accounts( id INTEGER PK, username TEXT UNIQUE, accounts( id INTEGER PK, username TEXT UNIQUE,
pw_argon2id TEXT, -- encoded hash incl. params + salt pw_argon2id TEXT, -- encoded hash incl. params + salt
created_at, last_login, flags ) created_at, last_login, flags )
bans( id, subject_type, subject, reason, expires_at ) bans( id, subject_type, subject, reason, expires_at, created_at )
``` ```
## 5. Permissions (scaffold for v1, enforced server-side) ## 5. Permissions (scaffold for v1, enforced server-side)

View File

@@ -22,6 +22,22 @@ static voicecat::v1::Envelope make_env(uint64_t req_id = 0) {
return e; return e;
} }
static voicecat::v1::Permissions all_permissions() {
voicecat::v1::Permissions p;
p.set_can_create_temp_channel(true);
p.set_can_kick(true);
p.set_can_ban(true);
p.set_can_move_users(true);
p.set_can_admin_accounts(true);
p.set_is_admin(true);
return p;
}
static voicecat::v1::Permissions no_permissions() {
voicecat::v1::Permissions p;
return p;
}
ConnSession::ConnSession(std::shared_ptr<Database> db, ConnSession::ConnSession(std::shared_ptr<Database> db,
std::shared_ptr<SessionRegistry> registry, std::shared_ptr<SessionRegistry> registry,
std::shared_ptr<voicecat::WorkerPool> workers, std::shared_ptr<voicecat::WorkerPool> workers,
@@ -73,7 +89,7 @@ void ConnSession::on_frame(std::vector<uint8_t> frame) {
break; break;
case voicecat::v1::Envelope::kLeaveChannel: case voicecat::v1::Envelope::kLeaveChannel:
if (st == State::Authenticated) if (st == State::Authenticated)
registry_->set_user_channel(user_id_.load(), 1); handle_leave_channel();
break; break;
case voicecat::v1::Envelope::kUdpBinding: case voicecat::v1::Envelope::kUdpBinding:
if (st == State::Authenticated) if (st == State::Authenticated)
@@ -87,6 +103,54 @@ void ConnSession::on_frame(std::vector<uint8_t> frame) {
if (st == State::Authenticated) if (st == State::Authenticated)
handle_stream_stop(env.stream_stop()); handle_stream_stop(env.stream_stop());
break; break;
// ── M5 moderation / admin ─────────────────────────────────────────────
case voicecat::v1::Envelope::kKick:
if (st == State::Authenticated) handle_kick_request(env.request_id(), env.kick());
break;
case voicecat::v1::Envelope::kBan:
if (st == State::Authenticated) handle_ban_request(env.request_id(), env.ban());
break;
case voicecat::v1::Envelope::kSetPermission:
if (st == State::Authenticated)
handle_set_permission(env.request_id(), env.set_permission());
break;
case voicecat::v1::Envelope::kServerMute:
if (st == State::Authenticated)
handle_server_mute_request(env.request_id(), env.server_mute());
break;
case voicecat::v1::Envelope::kMoveUser:
if (st == State::Authenticated) handle_move_user(env.request_id(), env.move_user());
break;
case voicecat::v1::Envelope::kCreateChannel:
if (st == State::Authenticated)
handle_create_channel(env.request_id(), env.create_channel());
break;
case voicecat::v1::Envelope::kEditChannel:
if (st == State::Authenticated)
handle_edit_channel(env.request_id(), env.edit_channel());
break;
case voicecat::v1::Envelope::kDeleteChannel:
if (st == State::Authenticated)
handle_delete_channel(env.request_id(), env.delete_channel());
break;
case voicecat::v1::Envelope::kCreateAccount:
if (st == State::Authenticated)
handle_create_account(env.request_id(), env.create_account());
break;
case voicecat::v1::Envelope::kResetPassword:
if (st == State::Authenticated)
handle_reset_password(env.request_id(), env.reset_password());
break;
case voicecat::v1::Envelope::kDeleteAccount:
if (st == State::Authenticated)
handle_delete_account(env.request_id(), env.delete_account());
break;
case voicecat::v1::Envelope::kListAccounts:
if (st == State::Authenticated)
handle_list_accounts(env.request_id(), env.list_accounts());
break;
default: default:
break; break;
} }
@@ -149,6 +213,27 @@ asio::ip::udp::endpoint ConnSession::udp_endpoint() const {
return udp_ep_; return udp_ep_;
} }
// ── Permission helpers ───────────────────────────────────────────────────────
bool ConnSession::has_permission(bool (voicecat::v1::Permissions::* getter)() const) const {
return (permissions_.*getter)();
}
void ConnSession::set_permissions(const voicecat::v1::Permissions& perms) {
permissions_ = perms;
registry_->set_session_permissions(session_id_, perms);
}
void ConnSession::send_generic_result(uint64_t req_id, bool ok, uint32_t code,
const std::string& message) {
auto env = make_env(req_id);
auto* gr = env.mutable_generic_result();
gr->set_ok(ok);
gr->set_code(code);
gr->set_message(message);
send_envelope(env);
}
// ── Handlers ───────────────────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────────────
void ConnSession::handle_client_hello(uint64_t req_id, const voicecat::v1::ClientHello& msg) { void ConnSession::handle_client_hello(uint64_t req_id, const voicecat::v1::ClientHello& msg) {
@@ -190,6 +275,7 @@ void ConnSession::finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64
send_envelope(env); send_envelope(env);
return; return;
} }
voicecat::v1::User user; voicecat::v1::User user;
user.set_nickname(guest.nickname().empty() ? "Guest" : guest.nickname()); user.set_nickname(guest.nickname().empty() ? "Guest" : guest.nickname());
user.set_is_guest(true); user.set_is_guest(true);
@@ -200,6 +286,8 @@ void ConnSession::finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64
user_id_.store(uid, std::memory_order_relaxed); user_id_.store(uid, std::memory_order_relaxed);
state_.store(State::Authenticated, std::memory_order_release); state_.store(State::Authenticated, std::memory_order_release);
permissions_ = no_permissions();
registry_->set_session_permissions(session_id_, permissions_);
registry_->register_udp_token(udp_token_, session_id_); registry_->register_udp_token(udp_token_, session_id_);
{ {
@@ -208,6 +296,7 @@ void ConnSession::finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64
res->set_ok(true); res->set_ok(true);
res->set_session_id(session_id_); res->set_session_id(session_id_);
*res->mutable_self() = user; *res->mutable_self() = user;
*res->mutable_permissions() = permissions_;
res->set_udp_token(udp_token_.data(), udp_token_.size()); res->set_udp_token(udp_token_.data(), udp_token_.size());
send_envelope(env); send_envelope(env);
} }
@@ -220,6 +309,15 @@ void ConnSession::finish_password_auth(const std::string& username,
// Argon2id runs on the worker pool (deliberately slow). // Argon2id runs on the worker pool (deliberately slow).
auto self = shared_from_this(); auto self = shared_from_this();
workers_->post([self, username, password, req_id] { workers_->post([self, username, password, req_id] {
// M5: check username bans before verifying password.
if (self->db_->ban_check("username", username)) {
auto env = make_env(req_id);
env.mutable_auth_result()->set_ok(false);
env.mutable_auth_result()->set_error("account banned");
self->send_envelope(env);
return;
}
auto acc = self->db_->authenticate(username, password); auto acc = self->db_->authenticate(username, password);
if (!acc) { if (!acc) {
auto env = make_env(req_id); auto env = make_env(req_id);
@@ -238,6 +336,9 @@ void ConnSession::finish_password_auth(const std::string& username,
self->user_id_.store(uid, std::memory_order_relaxed); self->user_id_.store(uid, std::memory_order_relaxed);
self->state_.store(State::Authenticated, std::memory_order_release); self->state_.store(State::Authenticated, std::memory_order_release);
voicecat::v1::Permissions perms = acc->is_admin ? all_permissions() : no_permissions();
self->permissions_ = perms;
self->registry_->set_session_permissions(self->session_id_, perms);
self->registry_->register_udp_token(self->udp_token_, self->session_id_); self->registry_->register_udp_token(self->udp_token_, self->session_id_);
{ {
@@ -246,8 +347,7 @@ void ConnSession::finish_password_auth(const std::string& username,
res->set_ok(true); res->set_ok(true);
res->set_session_id(self->session_id_); res->set_session_id(self->session_id_);
*res->mutable_self() = user; *res->mutable_self() = user;
auto* perms = res->mutable_permissions(); *res->mutable_permissions() = perms;
perms->set_is_admin(acc->is_admin);
res->set_udp_token(self->udp_token_.data(), self->udp_token_.size()); res->set_udp_token(self->udp_token_.data(), self->udp_token_.size());
self->send_envelope(env); self->send_envelope(env);
} }
@@ -274,15 +374,75 @@ void ConnSession::broadcast_user_joined(const voicecat::v1::User& user) {
void ConnSession::handle_join_channel(uint64_t req_id, void ConnSession::handle_join_channel(uint64_t req_id,
const voicecat::v1::JoinChannelRequest& msg) { const voicecat::v1::JoinChannelRequest& msg) {
bool ok = registry_->set_user_channel(user_id_.load(), msg.channel_id()); uint32_t uid = user_id_.load();
auto ch = registry_->get_channel(msg.channel_id());
if (!ch) {
auto env = make_env(req_id);
auto* res = env.mutable_join_channel_result();
res->set_ok(false);
res->set_error("channel not found");
send_envelope(env);
return;
}
if (ch->password_protected()) {
if (!registry_->check_channel_password(msg.channel_id(), msg.password())) {
auto env = make_env(req_id);
auto* res = env.mutable_join_channel_result();
res->set_ok(false);
res->set_error("invalid channel password");
send_envelope(env);
return;
}
}
if (ch->max_users() > 0) {
auto members = registry_->find_channel_sessions(msg.channel_id(), session_id_);
if (members.size() >= ch->max_users()) {
auto env = make_env(req_id);
auto* res = env.mutable_join_channel_result();
res->set_ok(false);
res->set_error("channel is full");
send_envelope(env);
return;
}
}
bool ok = registry_->set_user_channel(uid, msg.channel_id());
auto env = make_env(req_id); auto env = make_env(req_id);
auto* res = env.mutable_join_channel_result(); auto* res = env.mutable_join_channel_result();
res->set_ok(ok); res->set_ok(ok);
if (!ok) res->set_error("channel not found"); if (!ok) {
else res->set_channel_id(msg.channel_id()); res->set_error("channel not found");
} else {
res->set_channel_id(msg.channel_id());
*res->mutable_audio() = ch->audio();
// Broadcast that this user changed channel.
if (auto updated_user = registry_->user_snapshot_user(uid)) {
auto bcast = make_env();
auto* ue = bcast.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = *updated_user;
registry_->broadcast(bcast, session_id_);
}
}
send_envelope(env); send_envelope(env);
} }
void ConnSession::handle_leave_channel() {
uint32_t uid = user_id_.load();
if (!uid) return;
if (!registry_->set_user_channel(uid, 1)) return;
if (auto updated_user = registry_->user_snapshot_user(uid)) {
auto bcast = make_env();
auto* ue = bcast.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = *updated_user;
registry_->broadcast(bcast, session_id_);
}
}
void ConnSession::handle_text_message(const voicecat::v1::TextMessage& msg) { void ConnSession::handle_text_message(const voicecat::v1::TextMessage& msg) {
using namespace std::chrono; using namespace std::chrono;
int64_t now_ms = duration_cast<milliseconds>( int64_t now_ms = duration_cast<milliseconds>(
@@ -404,6 +564,170 @@ void ConnSession::handle_stream_stop(const voicecat::v1::StreamStop& msg) {
} }
} }
// ── M5 handlers ──────────────────────────────────────────────────────────────
void ConnSession::handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
bool ok = registry_->kick_user(msg.user_id(), msg.reason());
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
}
void ConnSession::handle_ban_request(uint64_t req_id, const voicecat::v1::BanRequest& msg) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_ban)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
// Ban by username for persistence (nickname == username for password users).
// Also ban by runtime user_id for immediate effect.
if (auto target = registry_->find_session_by_user_id(msg.user_id())) {
if (!target->user_id()) {
send_generic_result(req_id, false, 3, "user not found");
return;
}
if (auto nick = registry_->user_nickname(target->user_id())) {
std::string err;
db_->ban_create("username", *nick, msg.reason(),
static_cast<int64_t>(msg.expires_unix_ms()), err);
}
}
bool ok = registry_->ban_user(msg.user_id(), msg.reason(),
static_cast<int64_t>(msg.expires_unix_ms()));
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
}
void ConnSession::handle_set_permission(uint64_t req_id,
const voicecat::v1::SetPermissionRequest& msg) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
auto target = registry_->find_session_by_user_id(msg.user_id());
if (!target) {
send_generic_result(req_id, false, 3, "user not online");
return;
}
target->set_permissions(msg.permissions());
send_generic_result(req_id, true, 0, "");
}
void ConnSession::handle_server_mute_request(uint64_t req_id,
const voicecat::v1::ServerMuteRequest& msg) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
bool ok = registry_->set_server_mute(msg.user_id(), msg.muted(), msg.deafened());
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
}
void ConnSession::handle_move_user(uint64_t req_id, const voicecat::v1::MoveUserRequest& msg) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_move_users)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
bool ok = registry_->move_user(msg.user_id(), msg.channel_id());
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user or channel not found");
}
void ConnSession::handle_create_channel(uint64_t req_id,
const voicecat::v1::CreateChannelRequest& msg) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_create_temp_channel)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
std::string error;
uint32_t id = registry_->create_channel(msg.channel(), msg.password(), error);
send_generic_result(req_id, id != 0, id != 0 ? 0 : 3,
id != 0 ? "" : (error.empty() ? "create failed" : error));
}
void ConnSession::handle_edit_channel(uint64_t req_id,
const voicecat::v1::EditChannelRequest& msg) {
if (!is_admin()) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
std::string error;
bool ok = registry_->update_channel(msg.channel(), msg.password(), error);
send_generic_result(req_id, ok, ok ? 0 : 3,
ok ? "" : (error.empty() ? "update failed" : error));
}
void ConnSession::handle_delete_channel(uint64_t req_id,
const voicecat::v1::DeleteChannelRequest& msg) {
if (!is_admin()) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
std::string error;
bool ok = registry_->delete_channel(msg.channel_id(), error);
send_generic_result(req_id, ok, ok ? 0 : 3,
ok ? "" : (error.empty() ? "delete failed" : error));
}
void ConnSession::handle_create_account(uint64_t req_id,
const voicecat::v1::CreateAccountRequest& msg) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
auto self = shared_from_this();
workers_->post([self, req_id, msg]() mutable {
std::string error;
auto acc = self->db_->create_account(msg.username(), msg.password(), false, error);
self->send_generic_result(req_id, acc.has_value(), acc.has_value() ? 0 : 3,
acc.has_value() ? "" : error);
});
}
void ConnSession::handle_reset_password(uint64_t req_id,
const voicecat::v1::ResetPasswordRequest& msg) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
auto self = shared_from_this();
workers_->post([self, req_id, msg]() mutable {
std::string error;
bool ok = self->db_->reset_password(msg.username(), msg.new_password(), error);
self->send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : error);
});
}
void ConnSession::handle_delete_account(uint64_t req_id,
const voicecat::v1::DeleteAccountRequest& msg) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
std::string error;
bool ok = db_->delete_account(msg.username(), error);
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : error);
}
void ConnSession::handle_list_accounts(uint64_t req_id,
const voicecat::v1::ListAccountsRequest& /*msg*/) {
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
send_generic_result(req_id, false, 6, "permission denied");
return;
}
auto env = make_env(req_id);
auto* lr = env.mutable_list_accounts_result();
for (const auto& acc : db_->list_accounts()) {
auto* e = lr->add_accounts();
e->set_username(acc.username);
e->set_is_admin(acc.is_admin);
e->set_created_at_unix_ms(static_cast<uint64_t>(acc.created_at) * 1000);
e->set_last_login_unix_ms(static_cast<uint64_t>(acc.last_login) * 1000);
}
send_envelope(env);
}
void ConnSession::send_disconnect_and_close(uint32_t code, const std::string& reason) { void ConnSession::send_disconnect_and_close(uint32_t code, const std::string& reason) {
auto env = make_env(); auto env = make_env();
auto* d = env.mutable_disconnect(); auto* d = env.mutable_disconnect();

View File

@@ -57,6 +57,10 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
void send_envelope(const voicecat::v1::Envelope& env); void send_envelope(const voicecat::v1::Envelope& env);
void close(); void close();
// Called by SessionRegistry for kick/ban. Public so the registry can forcibly
// close a session without making it a friend class.
void send_disconnect_and_close(uint32_t code, const std::string& reason);
// ── M2: media key injection (called from on_tls_ready) ─────────────────── // ── M2: media key injection (called from on_tls_ready) ───────────────────
void set_media_crypto(std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send, void set_media_crypto(std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send,
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv); std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv);
@@ -87,6 +91,21 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
void handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBinding& msg); void handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBinding& msg);
void handle_stream_announce(uint64_t req_id, const voicecat::v1::StreamAnnounce& msg); void handle_stream_announce(uint64_t req_id, const voicecat::v1::StreamAnnounce& msg);
void handle_stream_stop(const voicecat::v1::StreamStop& msg); void handle_stream_stop(const voicecat::v1::StreamStop& msg);
void handle_leave_channel();
// M5 handlers
void handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg);
void handle_ban_request(uint64_t req_id, const voicecat::v1::BanRequest& msg);
void handle_set_permission(uint64_t req_id, const voicecat::v1::SetPermissionRequest& msg);
void handle_server_mute_request(uint64_t req_id, const voicecat::v1::ServerMuteRequest& msg);
void handle_move_user(uint64_t req_id, const voicecat::v1::MoveUserRequest& msg);
void handle_create_channel(uint64_t req_id, const voicecat::v1::CreateChannelRequest& msg);
void handle_edit_channel(uint64_t req_id, const voicecat::v1::EditChannelRequest& msg);
void handle_delete_channel(uint64_t req_id, const voicecat::v1::DeleteChannelRequest& msg);
void handle_create_account(uint64_t req_id, const voicecat::v1::CreateAccountRequest& msg);
void handle_reset_password(uint64_t req_id, const voicecat::v1::ResetPasswordRequest& msg);
void handle_delete_account(uint64_t req_id, const voicecat::v1::DeleteAccountRequest& msg);
void handle_list_accounts(uint64_t req_id, const voicecat::v1::ListAccountsRequest& msg);
void finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64_t req_id); void finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64_t req_id);
void finish_password_auth(const std::string& username, const std::string& password, void finish_password_auth(const std::string& username, const std::string& password,
@@ -95,7 +114,13 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
const voicecat::v1::Permissions* perms = nullptr); const voicecat::v1::Permissions* perms = nullptr);
void send_state_snapshot(); void send_state_snapshot();
void broadcast_user_joined(const voicecat::v1::User& user); void broadcast_user_joined(const voicecat::v1::User& user);
void send_disconnect_and_close(uint32_t code, const std::string& reason); void send_generic_result(uint64_t req_id, bool ok, uint32_t code,
const std::string& message);
// Permission helpers.
bool has_permission(bool (voicecat::v1::Permissions::* getter)() const) const;
bool is_admin() const { return has_permission(&voicecat::v1::Permissions::is_admin); }
void set_permissions(const voicecat::v1::Permissions& perms);
std::shared_ptr<Database> db_; std::shared_ptr<Database> db_;
std::shared_ptr<SessionRegistry> registry_; std::shared_ptr<SessionRegistry> registry_;
@@ -126,6 +151,9 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
// support multiple concurrent streams (MIC + SCREEN_AUDIO + AUX_DEVICE) per user. // support multiple concurrent streams (MIC + SCREEN_AUDIO + AUX_DEVICE) per user.
uint32_t next_stream_id_{1}; uint32_t next_stream_id_{1};
std::vector<uint32_t> announced_stream_ids_; std::vector<uint32_t> announced_stream_ids_;
// M5: permissions granted at auth time (server-side authority).
voicecat::v1::Permissions permissions_;
}; };
} // namespace voicecat::server } // namespace voicecat::server

View File

@@ -4,6 +4,7 @@
#include <chrono> #include <chrono>
#include <cstring> #include <cstring>
#include <random>
#include <stdexcept> #include <stdexcept>
#include <sodium.h> #include <sodium.h>
@@ -13,7 +14,9 @@ namespace voicecat::server {
// ── Schema ──────────────────────────────────────────────────────────────────── // ── Schema ────────────────────────────────────────────────────────────────────
static constexpr const char* kCreateSchema = R"sql( static constexpr int kCurrentSchemaVersion = 2;
static constexpr const char* kCreateSchemaV1 = R"sql(
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
@@ -26,7 +29,38 @@ CREATE TABLE IF NOT EXISTS server_meta (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
); );
INSERT OR IGNORE INTO server_meta (key, value) VALUES ('schema_version', '1'); )sql";
static constexpr const char* kCreateSchemaV2 = R"sql(
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER NOT NULL DEFAULT 0,
name TEXT UNIQUE NOT NULL,
topic TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL DEFAULT '',
max_users INTEGER NOT NULL DEFAULT 0,
type INTEGER NOT NULL DEFAULT 0,
audio_codec INTEGER NOT NULL DEFAULT 0,
audio_mode INTEGER NOT NULL DEFAULT 0,
audio_sample_rate INTEGER NOT NULL DEFAULT 48000,
audio_bitrate_bps INTEGER NOT NULL DEFAULT 24000,
audio_frame_ms INTEGER NOT NULL DEFAULT 20,
audio_application INTEGER NOT NULL DEFAULT 0,
audio_fec INTEGER NOT NULL DEFAULT 1,
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
audio_dtx INTEGER NOT NULL DEFAULT 1,
audio_complexity INTEGER NOT NULL DEFAULT 5,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_type TEXT NOT NULL,
subject TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_bans_subject ON bans(subject_type, subject);
)sql"; )sql";
// ── Database ────────────────────────────────────────────────────────────────── // ── Database ──────────────────────────────────────────────────────────────────
@@ -50,7 +84,37 @@ bool Database::open(std::string& error) {
exec("PRAGMA journal_mode=WAL", error); exec("PRAGMA journal_mode=WAL", error);
exec("PRAGMA synchronous=NORMAL", error); exec("PRAGMA synchronous=NORMAL", error);
error.clear(); error.clear();
if (!exec(kCreateSchema, error)) return false;
if (!exec(kCreateSchemaV1, error)) return false;
if (!migrate(error)) return false;
return true;
}
bool Database::migrate(std::string& error) {
// Ensure server_meta row exists.
if (!exec("INSERT OR IGNORE INTO server_meta (key, value) VALUES ('schema_version', '1')",
error))
return false;
sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db_, "SELECT value FROM server_meta WHERE key='schema_version'",
-1, &stmt, nullptr);
int version = 1;
if (sqlite3_step(stmt) == SQLITE_ROW) {
version = std::max(1, std::atoi(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0))));
}
sqlite3_finalize(stmt);
if (version < 2) {
if (!exec(kCreateSchemaV2, error)) return false;
sqlite3_stmt* upd = nullptr;
sqlite3_prepare_v2(db_,
"INSERT OR REPLACE INTO server_meta (key, value) VALUES ('schema_version', ?)",
-1, &upd, nullptr);
sqlite3_bind_text(upd, 1, "2", -1, SQLITE_TRANSIENT);
sqlite3_step(upd);
sqlite3_finalize(upd);
}
return true; return true;
} }
@@ -63,6 +127,8 @@ bool Database::is_empty() {
return count == 0; return count == 0;
} }
// ── Accounts ──────────────────────────────────────────────────────────────────
std::optional<Account> Database::create_account(const std::string& username, std::optional<Account> Database::create_account(const std::string& username,
const std::string& password, const std::string& password,
bool is_admin, std::string& error) { bool is_admin, std::string& error) {
@@ -108,6 +174,7 @@ std::optional<Account> Database::create_account(const std::string& username,
bool Database::reset_password(const std::string& username, const std::string& new_password, bool Database::reset_password(const std::string& username, const std::string& new_password,
std::string& error) { std::string& error) {
if (new_password.empty()) { error = "password must not be empty"; return false; }
char hash[crypto_pwhash_STRBYTES]; char hash[crypto_pwhash_STRBYTES];
if (crypto_pwhash_str(hash, new_password.c_str(), new_password.size(), if (crypto_pwhash_str(hash, new_password.c_str(), new_password.size(),
crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_OPSLIMIT_INTERACTIVE,
@@ -194,6 +261,326 @@ std::optional<Account> Database::authenticate(const std::string& username,
return acc; return acc;
} }
// ── Channels ──────────────────────────────────────────────────────────────────
namespace {
void bind_audio(sqlite3_stmt* stmt, int base_idx, const voicecat::v1::AudioConfig& a) {
sqlite3_bind_int(stmt, base_idx + 0, static_cast<int>(a.codec()));
sqlite3_bind_int(stmt, base_idx + 1, static_cast<int>(a.mode()));
sqlite3_bind_int(stmt, base_idx + 2, static_cast<int>(a.sample_rate()));
sqlite3_bind_int(stmt, base_idx + 3, static_cast<int>(a.bitrate_bps()));
sqlite3_bind_int(stmt, base_idx + 4, static_cast<int>(a.frame_ms()));
sqlite3_bind_int(stmt, base_idx + 5, static_cast<int>(a.application()));
sqlite3_bind_int(stmt, base_idx + 6, a.fec() ? 1 : 0);
sqlite3_bind_int(stmt, base_idx + 7, static_cast<int>(a.expected_packet_loss()));
sqlite3_bind_int(stmt, base_idx + 8, a.dtx() ? 1 : 0);
sqlite3_bind_int(stmt, base_idx + 9, static_cast<int>(a.complexity()));
}
voicecat::v1::AudioConfig read_audio(sqlite3_stmt* stmt, int base_idx) {
voicecat::v1::AudioConfig a;
a.set_codec(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 0)));
a.set_mode(static_cast<voicecat::v1::ChannelMode>(sqlite3_column_int(stmt, base_idx + 1)));
a.set_sample_rate(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 2)));
a.set_bitrate_bps(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 3)));
a.set_frame_ms(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 4)));
a.set_application(static_cast<voicecat::v1::OpusApplication>(sqlite3_column_int(stmt, base_idx + 5)));
a.set_fec(sqlite3_column_int(stmt, base_idx + 6) != 0);
a.set_expected_packet_loss(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 7)));
a.set_dtx(sqlite3_column_int(stmt, base_idx + 8) != 0);
a.set_complexity(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 9)));
return a;
}
} // namespace
std::optional<ChannelRecord> Database::create_channel(const ChannelRecord& ch,
const std::string& password,
std::string& error) {
if (ch.name.empty()) { error = "channel name must not be empty"; return std::nullopt; }
std::string pw_hash = password.empty() ? "" : hash_channel_password(password);
sqlite3_stmt* stmt = nullptr;
const char* sql = R"sql(
INSERT INTO channels
(parent_id, name, topic, password_hash, max_users, type,
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,
sort_order)
VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?)
)sql";
int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
sqlite3_bind_int(stmt, 1, static_cast<int>(ch.parent_id));
sqlite3_bind_text(stmt, 2, ch.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, ch.topic.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, pw_hash.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 5, static_cast<int>(ch.max_users));
sqlite3_bind_int(stmt, 6, static_cast<int>(ch.type));
bind_audio(stmt, 7, ch.audio); // parameters 7-16
sqlite3_bind_int(stmt, 17, static_cast<int>(ch.sort_order));
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return std::nullopt; }
ChannelRecord out = ch;
out.id = static_cast<uint32_t>(sqlite3_last_insert_rowid(db_));
out.password_protected = !pw_hash.empty();
return out;
}
bool Database::update_channel(const ChannelRecord& ch, const std::string& password,
std::string& error) {
std::string pw_hash;
bool update_password = false;
if (!password.empty()) {
pw_hash = hash_channel_password(password);
update_password = true;
}
std::string sql = R"sql(
UPDATE channels SET
parent_id=?, name=?, topic=?, max_users=?, type=?,
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=?,
sort_order=?
)sql";
if (update_password) sql += ", password_hash=?";
sql += " WHERE id=?";
sqlite3_stmt* stmt = nullptr;
int rc = sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return false; }
int idx = 1;
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.parent_id));
sqlite3_bind_text(stmt, idx++, ch.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, idx++, ch.topic.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.max_users));
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.type));
bind_audio(stmt, idx, ch.audio); idx += 10;
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.sort_order));
if (update_password) {
sqlite3_bind_text(stmt, idx++, pw_hash.c_str(), -1, SQLITE_TRANSIENT);
}
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.id));
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
if (sqlite3_changes(db_) == 0) { error = "channel not found"; return false; }
return true;
}
bool Database::delete_channel(uint32_t id, std::string& error) {
sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db_, "DELETE FROM channels WHERE id=?", -1, &stmt, nullptr);
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
if (sqlite3_changes(db_) == 0) { error = "channel not found"; return false; }
return true;
}
std::vector<ChannelRecord> Database::list_channels() {
std::vector<ChannelRecord> result;
sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db_, R"sql(
SELECT id, parent_id, name, topic, password_hash, max_users, type,
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,
sort_order
FROM channels
ORDER BY sort_order, id
)sql", -1, &stmt, nullptr);
while (sqlite3_step(stmt) == SQLITE_ROW) {
ChannelRecord ch;
ch.id = static_cast<uint32_t>(sqlite3_column_int(stmt, 0));
ch.parent_id = static_cast<uint32_t>(sqlite3_column_int(stmt, 1));
ch.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
ch.topic = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0';
ch.max_users = static_cast<uint32_t>(sqlite3_column_int(stmt, 5));
ch.type = static_cast<voicecat::v1::ChannelType>(sqlite3_column_int(stmt, 6));
ch.audio = read_audio(stmt, 7);
ch.sort_order = sqlite3_column_int(stmt, 17);
result.push_back(std::move(ch));
}
sqlite3_finalize(stmt);
return result;
}
std::optional<ChannelRecord> Database::get_channel(uint32_t id) {
sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db_, R"sql(
SELECT id, parent_id, name, topic, password_hash, max_users, type,
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,
sort_order
FROM channels WHERE id=?
)sql", -1, &stmt, nullptr);
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
std::optional<ChannelRecord> result;
if (sqlite3_step(stmt) == SQLITE_ROW) {
ChannelRecord ch;
ch.id = static_cast<uint32_t>(sqlite3_column_int(stmt, 0));
ch.parent_id = static_cast<uint32_t>(sqlite3_column_int(stmt, 1));
ch.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
ch.topic = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0';
ch.max_users = static_cast<uint32_t>(sqlite3_column_int(stmt, 5));
ch.type = static_cast<voicecat::v1::ChannelType>(sqlite3_column_int(stmt, 6));
ch.audio = read_audio(stmt, 7);
ch.sort_order = sqlite3_column_int(stmt, 17);
result = std::move(ch);
}
sqlite3_finalize(stmt);
return result;
}
std::string Database::hash_channel_password(const std::string& password) {
uint8_t salt[16];
randombytes_buf(salt, sizeof(salt));
uint8_t hash[32];
crypto_generichash(hash, sizeof(hash),
reinterpret_cast<const uint8_t*>(password.data()), password.size(),
salt, sizeof(salt));
char salt_hex[33];
char hash_hex[65];
sodium_bin2hex(salt_hex, sizeof(salt_hex), salt, sizeof(salt));
sodium_bin2hex(hash_hex, sizeof(hash_hex), hash, sizeof(hash));
return std::string(salt_hex) + ":" + hash_hex;
}
bool Database::verify_channel_password(const std::string& password, const std::string& stored) {
auto pos = stored.find(':');
if (pos == std::string::npos || pos != 32) return false;
std::string salt_hex = stored.substr(0, pos);
std::string hash_hex = stored.substr(pos + 1);
uint8_t salt[16];
uint8_t expected[32];
if (sodium_hex2bin(salt, sizeof(salt), salt_hex.c_str(), salt_hex.size(),
nullptr, nullptr, nullptr) != 0)
return false;
if (sodium_hex2bin(expected, sizeof(expected), hash_hex.c_str(), hash_hex.size(),
nullptr, nullptr, nullptr) != 0)
return false;
uint8_t hash[32];
crypto_generichash(hash, sizeof(hash),
reinterpret_cast<const uint8_t*>(password.data()), password.size(),
salt, sizeof(salt));
return sodium_memcmp(hash, expected, sizeof(hash)) == 0;
}
bool Database::check_channel_password(uint32_t id, const std::string& password) {
sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db_, "SELECT password_hash FROM channels WHERE id=?", -1, &stmt, nullptr);
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
bool ok = false;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char* stored = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
if (!stored || stored[0] == '\0') {
ok = true; // no password
} else {
ok = verify_channel_password(password, stored);
}
}
sqlite3_finalize(stmt);
return ok;
}
// ── Bans ───────────────────────────────────────────────────────────────────────
std::optional<BanRecord> Database::ban_create(const std::string& subject_type,
const std::string& subject,
const std::string& reason,
int64_t expires_at,
std::string& error) {
if (subject_type != "user_id" && subject_type != "username" && subject_type != "ip") {
error = "invalid subject_type";
return std::nullopt;
}
if (subject.empty()) { error = "subject must not be empty"; return std::nullopt; }
int64_t now = now_unix();
sqlite3_stmt* stmt = nullptr;
int rc = sqlite3_prepare_v2(db_,
"INSERT INTO bans (subject_type, subject, reason, expires_at, created_at) VALUES (?,?,?,?,?)",
-1, &stmt, nullptr);
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
sqlite3_bind_text(stmt, 1, subject_type.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, subject.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, reason.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 4, expires_at);
sqlite3_bind_int64(stmt, 5, now);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return std::nullopt; }
BanRecord b;
b.id = sqlite3_last_insert_rowid(db_);
b.subject_type = subject_type;
b.subject = subject;
b.reason = reason;
b.expires_at = expires_at;
b.created_at = now;
return b;
}
bool Database::ban_remove(int64_t id, std::string& error) {
sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db_, "DELETE FROM bans WHERE id=?", -1, &stmt, nullptr);
sqlite3_bind_int64(stmt, 1, id);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
if (sqlite3_changes(db_) == 0) { error = "ban not found"; return false; }
return true;
}
bool Database::ban_check(const std::string& subject_type, const std::string& subject) {
sqlite3_stmt* stmt = nullptr;
sqlite3_prepare_v2(db_,
"SELECT expires_at FROM bans WHERE subject_type=? AND subject=? AND (expires_at=0 OR expires_at>?)",
-1, &stmt, nullptr);
sqlite3_bind_text(stmt, 1, subject_type.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, subject.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 3, now_unix());
bool banned = false;
if (sqlite3_step(stmt) == SQLITE_ROW) banned = true;
sqlite3_finalize(stmt);
return banned;
}
std::vector<BanRecord> Database::ban_list(bool include_expired) {
std::vector<BanRecord> result;
sqlite3_stmt* stmt = nullptr;
std::string sql = "SELECT id, subject_type, subject, reason, expires_at, created_at FROM bans";
if (!include_expired) sql += " WHERE expires_at=0 OR expires_at>?";
sql += " ORDER BY created_at DESC";
sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
if (!include_expired) sqlite3_bind_int64(stmt, 1, now_unix());
while (sqlite3_step(stmt) == SQLITE_ROW) {
BanRecord b;
b.id = sqlite3_column_int64(stmt, 0);
b.subject_type = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
b.subject = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
b.reason = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
b.expires_at = sqlite3_column_int64(stmt, 4);
b.created_at = sqlite3_column_int64(stmt, 5);
result.push_back(std::move(b));
}
sqlite3_finalize(stmt);
return result;
}
std::string Database::generate_password(size_t length) { std::string Database::generate_password(size_t length) {
static const char kAlphabet[] = static const char kAlphabet[] =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*"; "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*";

View File

@@ -16,6 +16,8 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "proto/voicecat.pb.h"
struct sqlite3; struct sqlite3;
namespace voicecat::server { namespace voicecat::server {
@@ -28,6 +30,29 @@ struct Account {
int64_t last_login{}; int64_t last_login{};
}; };
// Persistent channel record. Mirrors voicecat::v1::Channel where applicable.
struct ChannelRecord {
uint32_t id{0};
uint32_t parent_id{0}; // 0 = root
std::string name;
std::string topic;
bool password_protected{false};
uint32_t max_users{0}; // 0 = unlimited
voicecat::v1::ChannelType type{voicecat::v1::CHANNEL_PERMANENT};
voicecat::v1::AudioConfig audio;
int32_t sort_order{0};
};
// Persistent ban record.
struct BanRecord {
int64_t id{0};
std::string subject_type; // "user_id" or "ip"
std::string subject; // the banned value
std::string reason;
int64_t expires_at{0}; // 0 = permanent
int64_t created_at{0};
};
class Database { class Database {
public: public:
explicit Database(std::string path); explicit Database(std::string path);
@@ -43,6 +68,8 @@ class Database {
// True if the accounts table has no rows. // True if the accounts table has no rows.
bool is_empty(); bool is_empty();
// ── Accounts ───────────────────────────────────────────────────────────────
// Create a new account. Hashes password with Argon2id. Thread-safe. // Create a new account. Hashes password with Argon2id. Thread-safe.
std::optional<Account> create_account(const std::string& username, std::optional<Account> create_account(const std::string& username,
const std::string& password, const std::string& password,
@@ -63,13 +90,62 @@ class Database {
std::optional<Account> authenticate(const std::string& username, std::optional<Account> authenticate(const std::string& username,
const std::string& password); const std::string& password);
// ── Channels ───────────────────────────────────────────────────────────────
// Create a new channel. `password` may be empty. Returns the persisted record
// (with assigned id), or nullopt on error. Thread-safe.
std::optional<ChannelRecord> create_channel(const ChannelRecord& ch,
const std::string& password,
std::string& error);
// Update an existing channel. Returns false if not found or on error.
bool update_channel(const ChannelRecord& ch, const std::string& password,
std::string& error);
// Delete a channel. Returns false if not found or on error.
bool delete_channel(uint32_t id, std::string& error);
// Load all channels. Thread-safe.
std::vector<ChannelRecord> list_channels();
// Fetch a single channel. Thread-safe.
std::optional<ChannelRecord> get_channel(uint32_t id);
// Check a plaintext channel password. Returns true if the channel has no
// password or if the password matches. Fast (BLAKE2b), safe for net thread.
bool check_channel_password(uint32_t id, const std::string& password);
// ── Bans ─────────────────────────────────────────────────────────────────────
// Ban a subject (user_id or ip). Returns the persisted record, or nullopt.
std::optional<BanRecord> ban_create(const std::string& subject_type,
const std::string& subject,
const std::string& reason,
int64_t expires_at,
std::string& error);
// Remove a ban by id. Returns false if not found or on error.
bool ban_remove(int64_t id, std::string& error);
// Check whether a subject is currently banned. Thread-safe.
bool ban_check(const std::string& subject_type, const std::string& subject);
// List active bans (optionally all). Thread-safe.
std::vector<BanRecord> ban_list(bool include_expired = false);
// Generate a random printable password of the given length. // Generate a random printable password of the given length.
static std::string generate_password(size_t length = 20); static std::string generate_password(size_t length = 20);
private: private:
bool exec(const std::string& sql, std::string& error); bool exec(const std::string& sql, std::string& error);
bool migrate(std::string& error);
int64_t now_unix() const; int64_t now_unix() const;
// Channel password hashing (fast, net-thread-safe).
static std::string hash_channel_password(const std::string& password);
static bool verify_channel_password(const std::string& password,
const std::string& stored);
std::string path_; std::string path_;
sqlite3* db_{nullptr}; sqlite3* db_{nullptr};
}; };

View File

@@ -54,8 +54,8 @@ int Server::run() {
} }
// ── Session registry ───────────────────────────────────────────────────── // ── Session registry ─────────────────────────────────────────────────────
auto registry = std::make_shared<SessionRegistry>(); auto registry = std::make_shared<SessionRegistry>(db);
registry->init_default_channels(); registry->load_channels();
// ── Worker pool ────────────────────────────────────────────────────────── // ── Worker pool ──────────────────────────────────────────────────────────
auto workers = std::make_shared<WorkerPool>(3); auto workers = std::make_shared<WorkerPool>(3);

View File

@@ -10,54 +10,82 @@
namespace voicecat::server { namespace voicecat::server {
void SessionRegistry::init_default_channels() { SessionRegistry::SessionRegistry(std::shared_ptr<Database> db) : db_(std::move(db)) {}
void SessionRegistry::load_channels() {
std::unique_lock lk(mu_); std::unique_lock lk(mu_);
ChannelEntry lobby; channels_.clear();
lobby.proto.set_id(1);
lobby.proto.set_name("Lobby");
lobby.proto.set_type(voicecat::v1::CHANNEL_PERMANENT);
lobby.proto.set_order(0);
// Non-zero so vc_list_channels/SessionModel round-trip this field for real (a regression
// test for the M4 SessionModel field-population fix needs at least one non-default value).
lobby.proto.set_max_users(20);
{
// Speech profile: mono, low bitrate, FEC+DTX on for resilience/silence-suppression.
auto* a = lobby.proto.mutable_audio();
a->set_codec(0);
a->set_mode(voicecat::v1::MODE_MONO);
a->set_sample_rate(48000);
a->set_bitrate_bps(24000);
a->set_frame_ms(20);
a->set_application(voicecat::v1::OPUS_VOIP);
a->set_fec(true);
a->set_expected_packet_loss(10);
a->set_dtx(true);
a->set_complexity(5);
}
channels_[1] = std::move(lobby);
ChannelEntry music; auto records = db_->list_channels();
music.proto.set_id(2); if (records.empty()) {
music.proto.set_name("Music Room"); // First run: seed the default channel tree.
music.proto.set_type(voicecat::v1::CHANNEL_PERMANENT); seed_default_channels();
music.proto.set_order(1); records = db_->list_channels();
{
// Music/screen-audio profile: stereo, high bitrate, FEC/DTX off (continuous signal).
auto* a = music.proto.mutable_audio();
a->set_codec(0);
a->set_mode(voicecat::v1::MODE_STEREO);
a->set_sample_rate(48000);
a->set_bitrate_bps(128000);
a->set_frame_ms(20);
a->set_application(voicecat::v1::OPUS_AUDIO);
a->set_fec(false);
a->set_expected_packet_loss(0);
a->set_dtx(false);
a->set_complexity(8);
} }
channels_[2] = std::move(music);
next_channel_id_ = 3; // 1 and 2 are now reserved (Lobby, Music Room) uint32_t max_id = 2;
for (auto& rec : records) {
ChannelEntry entry;
entry.proto.set_id(rec.id);
entry.proto.set_parent_id(rec.parent_id);
entry.proto.set_name(rec.name);
entry.proto.set_topic(rec.topic);
entry.proto.set_password_protected(rec.password_protected);
entry.proto.set_max_users(rec.max_users);
entry.proto.set_type(rec.type);
*entry.proto.mutable_audio() = rec.audio;
entry.proto.set_order(rec.sort_order);
max_id = std::max(max_id, rec.id);
channels_[rec.id] = std::move(entry);
}
next_channel_id_ = max_id + 1;
}
void SessionRegistry::seed_default_channels() {
// Lobby: speech profile.
ChannelRecord lobby;
lobby.id = 1;
lobby.name = "Lobby";
lobby.type = voicecat::v1::CHANNEL_PERMANENT;
lobby.sort_order = 0;
lobby.max_users = 20;
{
auto& a = lobby.audio;
a.set_codec(0);
a.set_mode(voicecat::v1::MODE_MONO);
a.set_sample_rate(48000);
a.set_bitrate_bps(24000);
a.set_frame_ms(20);
a.set_application(voicecat::v1::OPUS_VOIP);
a.set_fec(true);
a.set_expected_packet_loss(10);
a.set_dtx(true);
a.set_complexity(5);
}
// Music Room: stereo/music profile.
ChannelRecord music;
music.id = 2;
music.name = "Music Room";
music.type = voicecat::v1::CHANNEL_PERMANENT;
music.sort_order = 1;
{
auto& a = music.audio;
a.set_codec(0);
a.set_mode(voicecat::v1::MODE_STEREO);
a.set_sample_rate(48000);
a.set_bitrate_bps(128000);
a.set_frame_ms(20);
a.set_application(voicecat::v1::OPUS_AUDIO);
a.set_fec(false);
a.set_expected_packet_loss(0);
a.set_dtx(false);
a.set_complexity(8);
}
std::string err;
db_->create_channel(lobby, "", err);
db_->create_channel(music, "", err);
} }
uint64_t SessionRegistry::register_session(std::weak_ptr<ConnSession> session) { uint64_t SessionRegistry::register_session(std::weak_ptr<ConnSession> session) {
@@ -70,6 +98,7 @@ uint64_t SessionRegistry::register_session(std::weak_ptr<ConnSession> session) {
void SessionRegistry::unregister_session(uint64_t session_id) { void SessionRegistry::unregister_session(uint64_t session_id) {
std::unique_lock lk(mu_); std::unique_lock lk(mu_);
sessions_.erase(session_id); sessions_.erase(session_id);
session_permissions_.erase(session_id);
} }
uint32_t SessionRegistry::add_user(uint64_t session_id, const voicecat::v1::User& user) { uint32_t SessionRegistry::add_user(uint64_t session_id, const voicecat::v1::User& user) {
@@ -115,6 +144,20 @@ std::vector<voicecat::v1::User> SessionRegistry::user_snapshot() const {
return result; return result;
} }
std::optional<voicecat::v1::User> SessionRegistry::user_snapshot_user(uint32_t user_id) const {
std::shared_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return std::nullopt;
return it->second.proto;
}
std::optional<std::string> SessionRegistry::user_nickname(uint32_t user_id) const {
std::shared_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return std::nullopt;
return it->second.proto.nickname();
}
std::vector<std::shared_ptr<ConnSession>> SessionRegistry::resolve_text_targets( std::vector<std::shared_ptr<ConnSession>> SessionRegistry::resolve_text_targets(
uint64_t sender_session_id, voicecat::v1::TextScope scope, uint32_t target_id) const { uint64_t sender_session_id, voicecat::v1::TextScope scope, uint32_t target_id) const {
std::shared_lock lk(mu_); std::shared_lock lk(mu_);
@@ -145,13 +188,220 @@ std::vector<std::shared_ptr<ConnSession>> SessionRegistry::resolve_text_targets(
void SessionRegistry::broadcast(const voicecat::v1::Envelope& env, void SessionRegistry::broadcast(const voicecat::v1::Envelope& env,
uint64_t exclude_session_id) const { uint64_t exclude_session_id) const {
std::shared_lock lk(mu_); std::shared_lock lk(mu_);
broadcast_unlocked(env, exclude_session_id);
}
void SessionRegistry::broadcast_unlocked(const voicecat::v1::Envelope& env,
uint64_t exclude_session_id) const {
for (auto& [sid, weak] : sessions_) { for (auto& [sid, weak] : sessions_) {
if (sid == exclude_session_id) continue; if (sid == exclude_session_id) continue;
if (auto sess = weak.lock()) sess->send_envelope(env); if (auto sess = weak.lock()) sess->send_envelope(env);
} }
} }
// ── M2: UDP / media ────────────────────────────────────────────────────────── // ── Permissions ───────────────────────────────────────────────────────────────
void SessionRegistry::set_session_permissions(uint64_t session_id,
const voicecat::v1::Permissions& perms) {
std::unique_lock lk(mu_);
session_permissions_[session_id] = perms;
}
std::optional<voicecat::v1::Permissions> SessionRegistry::get_session_permissions(
uint64_t session_id) const {
std::shared_lock lk(mu_);
auto it = session_permissions_.find(session_id);
if (it == session_permissions_.end()) return std::nullopt;
return it->second;
}
// ── Moderation ────────────────────────────────────────────────────────────────
std::shared_ptr<ConnSession> SessionRegistry::find_session_by_user_id(uint32_t user_id) const {
std::shared_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return nullptr;
auto sit = sessions_.find(it->second.session_id);
if (sit == sessions_.end()) return nullptr;
return sit->second.lock();
}
namespace {
voicecat::v1::Envelope make_left_event(uint32_t user_id, const std::string& reason) {
voicecat::v1::Envelope env;
auto* ue = env.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::LEFT);
ue->mutable_user()->set_id(user_id);
ue->set_left_id(user_id);
ue->set_reason(reason); // M5 additive field
return env;
}
}
bool SessionRegistry::kick_user(uint32_t user_id, const std::string& reason) {
auto target = find_session_by_user_id(user_id);
if (!target) return false;
{
std::shared_lock lk(mu_);
broadcast(make_left_event(user_id, reason), /*exclude*/ 0);
}
// Close outside the registry lock: close() may call back into unregister_session().
target->send_disconnect_and_close(2, reason); // code 2 = kicked
return true;
}
bool SessionRegistry::ban_user(uint32_t user_id, const std::string& reason, int64_t expires_at) {
{
std::unique_lock lk(mu_);
auto it = users_.find(user_id);
if (it != users_.end()) {
std::string err;
db_->ban_create("user_id", std::to_string(user_id), reason, expires_at, err);
}
}
return kick_user(user_id, reason);
}
bool SessionRegistry::set_server_mute(uint32_t user_id, bool muted, bool deafened) {
std::unique_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return false;
it->second.proto.set_server_muted(muted);
it->second.proto.set_server_deafened(deafened);
voicecat::v1::Envelope env;
auto* ue = env.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = it->second.proto;
broadcast_unlocked(env, 0);
return true;
}
bool SessionRegistry::move_user(uint32_t user_id, uint32_t channel_id) {
std::unique_lock lk(mu_);
auto ch_it = channels_.find(channel_id);
if (ch_it == channels_.end()) return false;
auto user_it = users_.find(user_id);
if (user_it == users_.end()) return false;
user_it->second.proto.set_channel_id(channel_id);
voicecat::v1::Envelope env;
auto* ue = env.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = user_it->second.proto;
broadcast_unlocked(env, 0);
return true;
}
// ── Channel CRUD ──────────────────────────────────────────────────────────────
uint32_t SessionRegistry::create_channel(const voicecat::v1::Channel& ch,
const std::string& password,
std::string& error) {
ChannelRecord rec;
rec.parent_id = ch.parent_id();
rec.name = ch.name();
rec.topic = ch.topic();
rec.max_users = ch.max_users();
rec.type = ch.type();
rec.audio = ch.audio();
rec.sort_order = ch.order();
auto result = db_->create_channel(rec, password, error);
if (!result) return 0;
std::unique_lock lk(mu_);
uint32_t id = result->id;
ChannelEntry entry;
entry.proto = ch;
entry.proto.set_id(id);
entry.proto.set_password_protected(result->password_protected);
voicecat::v1::Envelope env;
auto* ce = env.mutable_channel_event();
ce->set_kind(voicecat::v1::ChannelEvent::CREATED);
*ce->mutable_channel() = entry.proto;
channels_[id] = std::move(entry);
next_channel_id_ = std::max(next_channel_id_, id + 1);
broadcast_unlocked(env, 0);
return id;
}
bool SessionRegistry::update_channel(const voicecat::v1::Channel& ch,
const std::string& password,
std::string& error) {
ChannelRecord rec;
rec.id = ch.id();
rec.parent_id = ch.parent_id();
rec.name = ch.name();
rec.topic = ch.topic();
rec.max_users = ch.max_users();
rec.type = ch.type();
rec.audio = ch.audio();
rec.sort_order = ch.order();
if (!db_->update_channel(rec, password, error)) return false;
std::unique_lock lk(mu_);
auto it = channels_.find(ch.id());
if (it == channels_.end()) {
error = "channel not found";
return false;
}
it->second.proto = ch;
it->second.proto.set_password_protected(
!password.empty() || db_->get_channel(ch.id())->password_protected);
voicecat::v1::Envelope env;
auto* ce = env.mutable_channel_event();
ce->set_kind(voicecat::v1::ChannelEvent::UPDATED);
*ce->mutable_channel() = it->second.proto;
broadcast_unlocked(env, 0);
return true;
}
bool SessionRegistry::delete_channel(uint32_t channel_id, std::string& error) {
if (channel_id == 1) { error = "cannot delete root channel"; return false; }
if (!db_->delete_channel(channel_id, error)) return false;
std::unique_lock lk(mu_);
channels_.erase(channel_id);
// Move any users left in the deleted channel to Lobby.
for (auto& [uid, entry] : users_) {
if (entry.proto.channel_id() != channel_id) continue;
entry.proto.set_channel_id(1);
voicecat::v1::Envelope uev;
auto* ue = uev.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = entry.proto;
broadcast_unlocked(uev, 0);
}
voicecat::v1::Envelope env;
auto* ce = env.mutable_channel_event();
ce->set_kind(voicecat::v1::ChannelEvent::DELETED);
ce->set_deleted_id(channel_id);
broadcast_unlocked(env, 0);
return true;
}
std::optional<voicecat::v1::Channel> SessionRegistry::get_channel(uint32_t channel_id) const {
std::shared_lock lk(mu_);
auto it = channels_.find(channel_id);
if (it == channels_.end()) return std::nullopt;
return it->second.proto;
}
bool SessionRegistry::check_channel_password(uint32_t channel_id,
const std::string& password) const {
return db_->check_channel_password(channel_id, password);
}
// ── M2: UDP / media ───────────────────────────────────────────────────────────
void SessionRegistry::register_udp_token(const std::array<uint8_t, 16>& token, void SessionRegistry::register_udp_token(const std::array<uint8_t, 16>& token,
uint64_t session_id) { uint64_t session_id) {

View File

@@ -23,6 +23,7 @@
#define ASIO_STANDALONE 1 #define ASIO_STANDALONE 1
#include <asio.hpp> #include <asio.hpp>
#include "db.h"
#include "proto/voicecat.pb.h" #include "proto/voicecat.pb.h"
namespace voicecat::server { namespace voicecat::server {
@@ -48,10 +49,10 @@ struct UdpEndpointHash {
class SessionRegistry { class SessionRegistry {
public: public:
SessionRegistry() = default; explicit SessionRegistry(std::shared_ptr<Database> db);
// Create the default "Lobby" channel (id=1, permanent). Call once at startup. // Load channels from the database, seeding defaults on first run.
void init_default_channels(); void load_channels();
// Register a session (before auth). Returns the assigned session_id. // Register a session (before auth). Returns the assigned session_id.
uint64_t register_session(std::weak_ptr<ConnSession> session); uint64_t register_session(std::weak_ptr<ConnSession> session);
@@ -71,6 +72,8 @@ class SessionRegistry {
// Snapshot for ServerStateSnapshot message. // Snapshot for ServerStateSnapshot message.
std::vector<voicecat::v1::Channel> channel_snapshot() const; std::vector<voicecat::v1::Channel> channel_snapshot() const;
std::vector<voicecat::v1::User> user_snapshot() const; std::vector<voicecat::v1::User> user_snapshot() const;
std::optional<voicecat::v1::User> user_snapshot_user(uint32_t user_id) const;
std::optional<std::string> user_nickname(uint32_t user_id) const;
// Resolve target sessions for a text message relay. // Resolve target sessions for a text message relay.
std::vector<std::shared_ptr<ConnSession>> resolve_text_targets( std::vector<std::shared_ptr<ConnSession>> resolve_text_targets(
@@ -79,7 +82,56 @@ class SessionRegistry {
// Broadcast an envelope to all sessions except the excluded one. // Broadcast an envelope to all sessions except the excluded one.
void broadcast(const voicecat::v1::Envelope& env, uint64_t exclude_session_id = 0) const; void broadcast(const voicecat::v1::Envelope& env, uint64_t exclude_session_id = 0) const;
// ── M2: UDP / media ────────────────────────────────────────────────────── private:
void broadcast_unlocked(const voicecat::v1::Envelope& env,
uint64_t exclude_session_id = 0) const;
public:
// ── Permissions ────────────────────────────────────────────────────────────
void set_session_permissions(uint64_t session_id,
const voicecat::v1::Permissions& perms);
std::optional<voicecat::v1::Permissions> get_session_permissions(
uint64_t session_id) const;
// ── Moderation ─────────────────────────────────────────────────────────────
// Find a live session by its user_id. Returns nullptr if offline.
std::shared_ptr<ConnSession> find_session_by_user_id(uint32_t user_id) const;
// Forcibly disconnect a user with a reason. Broadcasts UserEvent::LEFT.
// Returns true if the user was online.
bool kick_user(uint32_t user_id, const std::string& reason);
// Kick a user and insert a persistent ban. Returns true if the user was online.
bool ban_user(uint32_t user_id, const std::string& reason, int64_t expires_at);
// Set server-mute/deafen flags on a user and broadcast the update.
bool set_server_mute(uint32_t user_id, bool muted, bool deafened);
// Move a user to a channel (permission-checked by caller).
bool move_user(uint32_t user_id, uint32_t channel_id);
// ── Channel CRUD ───────────────────────────────────────────────────────────
// Create a channel. Returns the new channel id, or 0 on error.
uint32_t create_channel(const voicecat::v1::Channel& ch, const std::string& password,
std::string& error);
// Update a channel. Returns false on error.
bool update_channel(const voicecat::v1::Channel& ch, const std::string& password,
std::string& error);
// Delete a channel. Remaining users are moved to Lobby (id=1). Returns false on error.
bool delete_channel(uint32_t channel_id, std::string& error);
// Return a channel proto by id, or nullopt.
std::optional<voicecat::v1::Channel> get_channel(uint32_t channel_id) const;
// Check a channel password.
bool check_channel_password(uint32_t channel_id, const std::string& password) const;
// ── M2: UDP / media ────────────────────────────────────────────────────────
// Register a session's UDP token (called at auth success). // Register a session's UDP token (called at auth success).
void register_udp_token(const std::array<uint8_t, 16>& token, uint64_t session_id); void register_udp_token(const std::array<uint8_t, 16>& token, uint64_t session_id);
@@ -119,15 +171,20 @@ class SessionRegistry {
std::optional<voicecat::v1::AudioConfig> channel_audio_config(uint32_t channel_id) const; std::optional<voicecat::v1::AudioConfig> channel_audio_config(uint32_t channel_id) const;
private: private:
void seed_default_channels();
mutable std::shared_mutex mu_; mutable std::shared_mutex mu_;
std::shared_ptr<Database> db_;
uint64_t next_session_id_{1}; uint64_t next_session_id_{1};
uint32_t next_user_id_{1}; uint32_t next_user_id_{1};
uint32_t next_channel_id_{2}; // 1 is reserved for Lobby uint32_t next_channel_id_{3}; // 1 and 2 are reserved for Lobby, Music Room
std::unordered_map<uint64_t, std::weak_ptr<ConnSession>> sessions_; std::unordered_map<uint64_t, std::weak_ptr<ConnSession>> sessions_;
std::unordered_map<uint32_t, UserEntry> users_; std::unordered_map<uint32_t, UserEntry> users_;
std::unordered_map<uint32_t, ChannelEntry> channels_; std::unordered_map<uint32_t, ChannelEntry> channels_;
std::unordered_map<uint64_t, voicecat::v1::Permissions> session_permissions_;
// M2: token → session_id (populated at auth, cleared on disconnect) // M2: token → session_id (populated at auth, cleared on disconnect)
struct TokenHash { struct TokenHash {
@@ -150,5 +207,5 @@ class SessionRegistry {
} // namespace voicecat::server } // namespace voicecat::server
#endif // VOICECAT_HAS_NET #endif // VOICECAT_SERVER_SESSION_REGISTRY_H
#endif // VOICECAT_SERVER_SESSION_REGISTRY_H #endif // VOICECAT_SERVER_SESSION_REGISTRY_H

View File

@@ -113,4 +113,36 @@ if(VOICECAT_USE_VCPKG_DEPS)
target_include_directories(test_tofu_flow PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) target_include_directories(test_tofu_flow PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME tofu_flow COMMAND test_tofu_flow) add_test(NAME tofu_flow COMMAND test_tofu_flow)
set_tests_properties(tofu_flow PROPERTIES TIMEOUT 90) set_tests_properties(tofu_flow PROPERTIES TIMEOUT 90)
# M5 Phase 1: permission enforcement + SetPermissionRequest + channel create.
add_executable(test_m5_permissions test_m5_permissions.cpp)
target_link_libraries(test_m5_permissions PRIVATE voicecat::server)
target_compile_features(test_m5_permissions PRIVATE cxx_std_20)
target_include_directories(test_m5_permissions PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m5_permissions COMMAND test_m5_permissions)
set_tests_properties(m5_permissions PROPERTIES TIMEOUT 60)
# M5 Phase 2: kick/ban/move/server-mute.
add_executable(test_m5_kick_ban_move_mute test_m5_kick_ban_move_mute.cpp)
target_link_libraries(test_m5_kick_ban_move_mute PRIVATE voicecat::server)
target_compile_features(test_m5_kick_ban_move_mute PRIVATE cxx_std_20)
target_include_directories(test_m5_kick_ban_move_mute PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m5_kick_ban_move_mute COMMAND test_m5_kick_ban_move_mute)
set_tests_properties(m5_kick_ban_move_mute PROPERTIES TIMEOUT 90)
# M5 Phase 3: in-app admin account management.
add_executable(test_m5_admin_accounts test_m5_admin_accounts.cpp)
target_link_libraries(test_m5_admin_accounts PRIVATE voicecat::server)
target_compile_features(test_m5_admin_accounts PRIVATE cxx_std_20)
target_include_directories(test_m5_admin_accounts PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m5_admin_accounts COMMAND test_m5_admin_accounts)
set_tests_properties(m5_admin_accounts PROPERTIES TIMEOUT 90)
# M5 Phase 4: channel CRUD & password enforcement.
add_executable(test_m5_channel_crud test_m5_channel_crud.cpp)
target_link_libraries(test_m5_channel_crud PRIVATE voicecat::server)
target_compile_features(test_m5_channel_crud PRIVATE cxx_std_20)
target_include_directories(test_m5_channel_crud PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME m5_channel_crud COMMAND test_m5_channel_crud)
set_tests_properties(m5_channel_crud PROPERTIES TIMEOUT 90)
endif() endif()

View File

@@ -0,0 +1,276 @@
/*
* test_m5_admin_accounts — Phase 3 of M5: in-app account management over the wire.
*
* Verifies:
* - admin can create an account via vc_create_account.
* - the new account can authenticate.
* - admin can reset the password.
* - the new account can authenticate with the new password.
* - admin can list accounts and sees the new entry.
* - admin can delete the account.
* - the deleted account can no longer authenticate.
*/
#include <cstdio>
#include <cstring>
#ifdef VOICECAT_HAS_NET
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
bool auth_done{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool disconnected{false};
struct ResultAck {
bool ok{false};
uint32_t code{0};
std::string message;
};
std::vector<ResultAck> generic_results;
bool account_list_received{false};
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->auth_done = true;
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_GENERIC_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->u32a;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_ACCOUNT_LIST:
s->account_list_received = true;
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template<typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
static bool wait_generic(EventStore& s, int timeout_ms) {
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
}
static bool last_generic_ok(EventStore& s) {
std::lock_guard lk(s.mu);
return !s.generic_results.empty() && s.generic_results.back().ok;
}
static void reset_generic(EventStore& s) {
std::lock_guard lk(s.mu);
s.generic_results.clear();
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m5_acct_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
if (!db.create_account("admin", "admin-pass", true, err)) {
std::printf("FAIL: create admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-M5-Acct";
cfg.allow_guests = false;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
EventStore evAdmin;
evAdmin.label = "admin";
vc_callbacks cbA{on_event, nullptr, &evAdmin};
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
vc_client* admin = vc_client_create(&cfgA, cbA);
evAdmin.client = admin;
CHECK(admin != nullptr);
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
// Create charlie.
reset_generic(evAdmin);
CHECK(vc_create_account(admin, "charlie", "charlie-pass") == VC_OK);
CHECK(wait_generic(evAdmin, 5000));
CHECK(last_generic_ok(evAdmin));
// Charlie logs in.
{
EventStore evCharlie;
evCharlie.label = "charlie";
vc_callbacks cb{on_event, nullptr, &evCharlie};
vc_config cfgC{"test-charlie", "0.1", VC_LOG_OFF};
vc_client* charlie = vc_client_create(&cfgC, cb);
evCharlie.client = charlie;
CHECK(charlie != nullptr);
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(charlie, "charlie", "charlie-pass") == VC_OK);
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(evCharlie.auth_ok);
vc_disconnect(charlie);
vc_client_destroy(charlie);
}
// Reset charlie's password.
reset_generic(evAdmin);
CHECK(vc_reset_password(admin, "charlie", "new-pass") == VC_OK);
CHECK(wait_generic(evAdmin, 5000));
CHECK(last_generic_ok(evAdmin));
// Charlie logs in with new password.
{
EventStore evCharlie;
evCharlie.label = "charlie2";
vc_callbacks cb{on_event, nullptr, &evCharlie};
vc_config cfgC{"test-charlie2", "0.1", VC_LOG_OFF};
vc_client* charlie = vc_client_create(&cfgC, cb);
evCharlie.client = charlie;
CHECK(charlie != nullptr);
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(charlie, "charlie", "new-pass") == VC_OK);
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(evCharlie.auth_ok);
vc_disconnect(charlie);
vc_client_destroy(charlie);
}
// List accounts.
reset_generic(evAdmin);
evAdmin.account_list_received = false;
CHECK(vc_list_accounts(admin) == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.account_list_received; }, 3000));
// Delete charlie.
reset_generic(evAdmin);
CHECK(vc_delete_account(admin, "charlie") == VC_OK);
CHECK(wait_generic(evAdmin, 3000));
CHECK(last_generic_ok(evAdmin));
// Charlie cannot log in anymore.
{
EventStore evCharlie;
evCharlie.label = "charlie3";
vc_callbacks cb{on_event, nullptr, &evCharlie};
vc_config cfgC{"test-charlie3", "0.1", VC_LOG_OFF};
vc_client* charlie = vc_client_create(&cfgC, cb);
evCharlie.client = charlie;
CHECK(charlie != nullptr);
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(charlie, "charlie", "new-pass") == VC_OK);
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_done || s.disconnected; }, 20000));
CHECK(!evCharlie.auth_ok);
vc_disconnect(charlie);
vc_client_destroy(charlie);
}
vc_disconnect(admin);
vc_client_destroy(admin);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m5_admin_accounts: all checks passed\n");
return 0;
}
std::printf("m5_admin_accounts: %d failure(s)\n", g_failures);
return 1;
}
#else // !VOICECAT_HAS_NET
int main() {
std::printf("m5_admin_accounts: SKIP (VOICECAT_HAS_NET not defined)\n");
return 0;
}
#endif // VOICECAT_HAS_NET

View File

@@ -0,0 +1,319 @@
/*
* test_m5_channel_crud — Phase 4 of M5: channel CRUD & password enforcement.
*
* Verifies:
* - admin can create a password-protected channel.
* - normal user cannot join without the password.
* - normal user can join with the password.
* - admin can edit the channel name.
* - both clients receive VC_EVENT_CHANNEL_LIST after the update.
* - admin can delete the channel; remaining users are moved to Lobby.
*/
#include <cstdio>
#include <cstring>
#ifdef VOICECAT_HAS_NET
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
int channel_list_count{0};
bool disconnected{false};
struct ResultAck {
bool ok{false};
uint32_t code{0};
std::string message;
};
std::vector<ResultAck> generic_results;
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
++s->channel_list_count;
break;
case VC_EVENT_JOIN_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->channel_id;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_GENERIC_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->u32a;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template<typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
static bool wait_generic(EventStore& s, int timeout_ms) {
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
}
static bool last_generic_ok(EventStore& s) {
std::lock_guard lk(s.mu);
return !s.generic_results.empty() && s.generic_results.back().ok;
}
static void reset_generic(EventStore& s) {
std::lock_guard lk(s.mu);
s.generic_results.clear();
}
static int channel_count(vc_client* c) {
vc_channel_list cl{};
if (vc_list_channels(c, &cl) != VC_OK) return -1;
int n = static_cast<int>(cl.count);
vc_free_channel_list(&cl);
return n;
}
static uint32_t find_channel_by_name(vc_client* c, const char* name) {
vc_channel_list cl{};
if (vc_list_channels(c, &cl) != VC_OK) return 0;
uint32_t id = 0;
for (size_t i = 0; i < cl.count; ++i) {
if (std::strcmp(cl.items[i].name, name) == 0) {
id = cl.items[i].id;
break;
}
}
vc_free_channel_list(&cl);
return id;
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m5_ch_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
if (!db.create_account("admin", "admin-pass", true, err)) {
std::printf("FAIL: create admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
if (!db.create_account("bob", "bob-pass", false, err)) {
std::printf("FAIL: create bob: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-M5-Ch";
cfg.allow_guests = false;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
EventStore evAdmin;
evAdmin.label = "admin";
vc_callbacks cbA{on_event, nullptr, &evAdmin};
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
vc_client* admin = vc_client_create(&cfgA, cbA);
evAdmin.client = admin;
CHECK(admin != nullptr);
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_count > 0; }, 3000));
EventStore evBob;
evBob.label = "bob";
vc_callbacks cbB{on_event, nullptr, &evBob};
vc_config cfgB{"test-bob", "0.1", VC_LOG_OFF};
vc_client* bob = vc_client_create(&cfgB, cbB);
evBob.client = bob;
CHECK(bob != nullptr);
CHECK(vc_connect(bob, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(bob, "bob", "bob-pass") == VC_OK);
CHECK(wait_for(evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evBob, [](EventStore& s){ return s.channel_list_count > 0; }, 3000));
// Admin creates a password-protected channel.
reset_generic(evAdmin);
int base_count = channel_count(admin);
CHECK(base_count >= 2); // Lobby + Music Room
vc_channel_info ch{};
ch.name = "Private Room";
ch.topic = "secret";
ch.password_protected = 1;
ch.password = "swordfish";
ch.max_users = 10;
ch.sort_order = 5;
CHECK(vc_create_channel(admin, &ch) == VC_OK);
CHECK(wait_generic(evAdmin, 3000));
CHECK(last_generic_ok(evAdmin));
// Wait for the new channel to appear in bob's list.
CHECK(wait_for(evBob, [](EventStore& s){ return s.channel_list_count > 1; }, 3000));
int new_count = channel_count(bob);
CHECK(new_count == base_count + 1);
uint32_t private_id = find_channel_by_name(admin, "Private Room");
CHECK(private_id != 0);
// Bob tries to join without password — should fail.
reset_generic(evBob);
CHECK(vc_join_channel(bob, private_id, nullptr) == VC_OK);
CHECK(wait_generic(evBob, 3000));
CHECK(!last_generic_ok(evBob));
// Bob joins with password — should succeed.
reset_generic(evBob);
CHECK(vc_join_channel(bob, private_id, "swordfish") == VC_OK);
CHECK(wait_generic(evBob, 3000));
CHECK(last_generic_ok(evBob));
// Admin edits the channel name.
reset_generic(evAdmin);
int admin_list_count = evAdmin.channel_list_count;
vc_channel_info edit{};
edit.id = private_id;
edit.name = "Renamed Room";
edit.topic = "still secret";
edit.password_protected = 1;
edit.password = "swordfish"; // keep same password
edit.sort_order = 5;
CHECK(vc_edit_channel(admin, &edit) == VC_OK);
CHECK(wait_generic(evAdmin, 3000));
CHECK(last_generic_ok(evAdmin));
// Both clients should receive a channel-list update.
CHECK(wait_for(evAdmin, [admin_list_count](EventStore& s){ return s.channel_list_count > admin_list_count; }, 3000));
CHECK(wait_for(evBob, [private_id](EventStore& s){ return find_channel_by_name(s.client, "Renamed Room") == private_id; }, 3000));
// Admin deletes the channel.
reset_generic(evAdmin);
CHECK(vc_delete_channel(admin, private_id) == VC_OK);
CHECK(wait_generic(evAdmin, 3000));
CHECK(last_generic_ok(evAdmin));
// Bob should see the channel disappear and be back in Lobby (id=1).
CHECK(wait_for(evBob, [private_id](EventStore& s){ return find_channel_by_name(s.client, "Renamed Room") == 0; }, 3000));
{
vc_user_list ul{};
CHECK(vc_list_users(bob, &ul) == VC_OK);
bool found_self = false;
for (size_t i = 0; i < ul.count; ++i) {
if (ul.items[i].id == evBob.self_user_id) {
found_self = true;
CHECK(ul.items[i].channel_id == 1);
break;
}
}
CHECK(found_self);
vc_free_user_list(&ul);
}
vc_disconnect(admin);
vc_disconnect(bob);
vc_client_destroy(admin);
vc_client_destroy(bob);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m5_channel_crud: all checks passed\n");
return 0;
}
std::printf("m5_channel_crud: %d failure(s)\n", g_failures);
return 1;
}
#else // !VOICECAT_HAS_NET
int main() {
std::printf("m5_channel_crud: SKIP (VOICECAT_HAS_NET not defined)\n");
return 0;
}
#endif // VOICECAT_HAS_NET

View File

@@ -0,0 +1,331 @@
/*
* test_m5_kick_ban_move_mute — Phase 2 of M5: moderation.
*
* Verifies:
* - admin can kick a user (target receives disconnect).
* - admin can ban a password user; re-auth fails.
* - admin can move a user to another channel.
* - admin can server-mute/deafen a user; the target reflects it locally.
*/
#include <cstdio>
#include <cstring>
#ifdef VOICECAT_HAS_NET
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
bool auth_done{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool disconnected{false};
vc_result disconnect_reason{VC_OK};
std::string disconnect_text;
struct ResultAck {
bool ok{false};
uint32_t code{0};
std::string message;
};
std::vector<ResultAck> generic_results;
uint32_t last_updated_user{0};
uint32_t last_updated_channel{0};
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->auth_done = true;
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_GENERIC_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->u32a;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_USER_UPDATED:
s->last_updated_user = ev->user_id;
s->last_updated_channel = ev->channel_id;
break;
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
s->disconnect_reason = static_cast<vc_result>(ev->result);
s->disconnect_text = ev->text ? ev->text : "";
break;
default:
break;
}
s->cv.notify_all();
}
template<typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
static bool wait_generic(EventStore& s, int timeout_ms) {
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
}
static bool last_generic_ok(EventStore& s) {
std::lock_guard lk(s.mu);
return !s.generic_results.empty() && s.generic_results.back().ok;
}
static void reset_generic(EventStore& s) {
std::lock_guard lk(s.mu);
s.generic_results.clear();
}
static void reset_updated(EventStore& s) {
std::lock_guard lk(s.mu);
s.last_updated_user = 0;
s.last_updated_channel = 0;
}
static bool wait_updated(EventStore& s, uint32_t user_id, int timeout_ms) {
return wait_for(s, [user_id](EventStore& st) {
return st.last_updated_user == user_id;
}, timeout_ms);
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m5_mod_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
if (!db.create_account("admin", "admin-pass", true, err)) {
std::printf("FAIL: create admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
if (!db.create_account("alice", "alice-pass", false, err)) {
std::printf("FAIL: create alice: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
if (!db.create_account("bob", "bob-pass", false, err)) {
std::printf("FAIL: create bob: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-M5-Mod";
cfg.allow_guests = false;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
auto make_client = [&](const char* label, const char* user, const char* pass) -> EventStore* {
auto* ev = new EventStore();
ev->label = label;
vc_callbacks cb{on_event, nullptr, ev};
vc_config cfgx{label, "0.1", VC_LOG_OFF};
ev->client = vc_client_create(&cfgx, cb);
if (!ev->client) return nullptr;
if (vc_connect(ev->client, "127.0.0.1", port) != VC_OK) return nullptr;
if (vc_authenticate_user(ev->client, user, pass) != VC_OK) return nullptr;
return ev;
};
EventStore* evAdmin = make_client("admin", "admin", "admin-pass");
CHECK(evAdmin != nullptr);
CHECK(wait_for(*evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(*evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t admin_id = evAdmin->self_user_id;
CHECK(admin_id != 0);
EventStore* evAlice = make_client("alice", "alice", "alice-pass");
CHECK(evAlice != nullptr);
CHECK(wait_for(*evAlice, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(*evAlice, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t alice_id = evAlice->self_user_id;
CHECK(alice_id != 0);
EventStore* evBob = make_client("bob", "bob", "bob-pass");
CHECK(evBob != nullptr);
CHECK(wait_for(*evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(*evBob, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t bob_id = evBob->self_user_id;
CHECK(bob_id != 0);
// Kick bob.
reset_generic(*evAdmin);
CHECK(vc_kick_user(evAdmin->client, bob_id, "bye bob") == VC_OK);
CHECK(wait_generic(*evAdmin, 3000));
CHECK(last_generic_ok(*evAdmin));
CHECK(wait_for(*evBob, [](EventStore& s){ return s.disconnected; }, 3000));
CHECK(evBob->disconnect_reason == VC_ERR_IO);
vc_client_destroy(evBob->client);
delete evBob;
// Reconnect bob.
evBob = make_client("bob", "bob", "bob-pass");
CHECK(evBob != nullptr);
CHECK(wait_for(*evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(*evBob, [](EventStore& s){ return s.channel_list_received; }, 3000));
bob_id = evBob->self_user_id;
CHECK(bob_id != 0);
// Ban bob permanently.
reset_generic(*evAdmin);
CHECK(vc_ban_user(evAdmin->client, bob_id, "spam", 0) == VC_OK);
CHECK(wait_generic(*evAdmin, 3000));
CHECK(last_generic_ok(*evAdmin));
CHECK(wait_for(*evBob, [](EventStore& s){ return s.disconnected; }, 3000));
vc_client_destroy(evBob->client);
delete evBob;
// Bob tries to reconnect — should fail auth due to username ban.
{
EventStore evBanned;
evBanned.label = "banned-bob";
vc_callbacks cb{on_event, nullptr, &evBanned};
vc_config cfgb{"banned-bob", "0.1", VC_LOG_OFF};
vc_client* banned_bob = vc_client_create(&cfgb, cb);
evBanned.client = banned_bob;
CHECK(banned_bob != nullptr);
CHECK(vc_connect(banned_bob, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(banned_bob, "bob", "bob-pass") == VC_OK);
CHECK(wait_for(evBanned, [](EventStore& s){ return s.auth_done || s.disconnected; }, 20000));
CHECK(!evBanned.auth_ok); // banned
vc_disconnect(banned_bob);
vc_client_destroy(banned_bob);
}
// Move alice to Music Room (channel id 2).
reset_generic(*evAdmin);
reset_updated(*evAlice);
CHECK(vc_move_user(evAdmin->client, alice_id, 2) == VC_OK);
CHECK(wait_generic(*evAdmin, 3000));
CHECK(last_generic_ok(*evAdmin));
CHECK(wait_updated(*evAlice, alice_id, 3000));
{
vc_channel_list cl{};
CHECK(vc_list_channels(evAlice->client, &cl) == VC_OK);
CHECK(cl.count >= 2);
vc_free_channel_list(&cl);
vc_user_list ul{};
CHECK(vc_list_users(evAlice->client, &ul) == VC_OK);
bool found_alice = false;
for (size_t i = 0; i < ul.count; ++i) {
if (ul.items[i].id == alice_id) {
found_alice = true;
CHECK(ul.items[i].channel_id == 2);
break;
}
}
CHECK(found_alice);
vc_free_user_list(&ul);
}
// Server-mute alice.
reset_generic(*evAdmin);
reset_updated(*evAlice);
CHECK(vc_set_server_mute(evAdmin->client, alice_id, true, false) == VC_OK);
CHECK(wait_generic(*evAdmin, 3000));
CHECK(last_generic_ok(*evAdmin));
CHECK(wait_updated(*evAlice, alice_id, 3000));
// Cleanup.
vc_disconnect(evAdmin->client);
vc_disconnect(evAlice->client);
vc_client_destroy(evAdmin->client);
vc_client_destroy(evAlice->client);
delete evAdmin;
delete evAlice;
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m5_kick_ban_move_mute: all checks passed\n");
return 0;
}
std::printf("m5_kick_ban_move_mute: %d failure(s)\n", g_failures);
return 1;
}
#else // !VOICECAT_HAS_NET
int main() {
std::printf("m5_kick_ban_move_mute: SKIP (VOICECAT_HAS_NET not defined)\n");
return 0;
}
#endif // VOICECAT_HAS_NET

View File

@@ -0,0 +1,266 @@
/*
* test_m5_permissions — Phase 1 of M5: server-side permission enforcement.
*
* Verifies:
* - admin gets is_admin/can_create_temp_channel/etc.
* - normal password user gets no permissions.
* - admin can create a channel.
* - normal user cannot create a channel.
* - admin can grant can_create_temp_channel via vc_set_permission.
* - granted normal user can now create a channel.
*/
#include <cstdio>
#include <cstring>
#ifdef VOICECAT_HAS_NET
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "voicecat.h"
#include "server.h"
#include "db.h"
struct EventStore {
std::mutex mu;
std::condition_variable cv;
bool auth_ok{false};
uint32_t self_user_id{0};
bool channel_list_received{false};
bool disconnected{false};
// VC_EVENT_GENERIC_RESULT tracking
struct ResultAck {
bool ok{false};
uint32_t code{0};
std::string message;
};
std::vector<ResultAck> generic_results;
vc_client* client{nullptr};
const char* label{nullptr};
};
static void on_event(void* user, const vc_event* ev) {
auto* s = static_cast<EventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
vc_confirm_server_identity(s->client, 1);
break;
case VC_EVENT_AUTH_RESULT:
s->auth_ok = (ev->result == VC_OK);
s->self_user_id = ev->user_id;
break;
case VC_EVENT_CHANNEL_LIST:
s->channel_list_received = true;
break;
case VC_EVENT_GENERIC_RESULT: {
EventStore::ResultAck gr;
gr.ok = (ev->result == VC_OK);
gr.code = ev->u32a;
gr.message = ev->text ? ev->text : "";
s->generic_results.push_back(std::move(gr));
break;
}
case VC_EVENT_DISCONNECTED:
s->disconnected = true;
break;
default:
break;
}
s->cv.notify_all();
}
template<typename Pred>
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
std::unique_lock lk(s.mu);
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
}
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
++g_failures; \
} \
} while (0)
static bool wait_generic(EventStore& s, int timeout_ms) {
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
}
static bool last_generic_ok(EventStore& s) {
std::lock_guard lk(s.mu);
return !s.generic_results.empty() && s.generic_results.back().ok;
}
static void reset_generic(EventStore& s) {
std::lock_guard lk(s.mu);
s.generic_results.clear();
}
int main() {
auto tmp = std::filesystem::temp_directory_path() /
("vctest_m5_perm_" + std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(tmp);
std::string data_dir = tmp.string();
// Pre-provision admin and normal user.
{
voicecat::server::Database db(data_dir + "/voicecat.db");
std::string err;
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
if (!db.create_account("admin", "admin-pass", true, err)) {
std::printf("FAIL: create admin: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
if (!db.create_account("bob", "bob-pass", false, err)) {
std::printf("FAIL: create bob: %s\n", err.c_str());
std::filesystem::remove_all(tmp);
return 1;
}
}
std::atomic<uint16_t> bound_port{0};
std::mutex ready_mu;
std::condition_variable ready_cv;
bool ready{false};
voicecat::server::Config cfg;
cfg.data_dir = data_dir;
cfg.bind_port = 0;
cfg.server_name = "VoiceCat-M5-Perm";
cfg.allow_guests = false;
cfg.on_ready = [&](uint16_t p) {
bound_port.store(p);
{ std::lock_guard lk(ready_mu); ready = true; }
ready_cv.notify_all();
};
voicecat::server::Server server(cfg);
std::thread server_thread([&] { server.run(); });
{
std::unique_lock lk(ready_mu);
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
std::printf("FAIL: server did not become ready\n");
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
return 1;
}
}
uint16_t port = bound_port.load();
// Admin client.
EventStore evA;
evA.label = "admin";
vc_callbacks cbA{on_event, nullptr, &evA};
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
vc_client* admin = vc_client_create(&cfgA, cbA);
evA.client = admin;
CHECK(admin != nullptr);
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
CHECK(wait_for(evA, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evA, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t admin_id = evA.self_user_id;
CHECK(admin_id != 0);
// Normal client.
EventStore evB;
evB.label = "bob";
vc_callbacks cbB{on_event, nullptr, &evB};
vc_config cfgB{"test-bob", "0.1", VC_LOG_OFF};
vc_client* bob = vc_client_create(&cfgB, cbB);
evB.client = bob;
CHECK(bob != nullptr);
CHECK(vc_connect(bob, "127.0.0.1", port) == VC_OK);
CHECK(vc_authenticate_user(bob, "bob", "bob-pass") == VC_OK);
CHECK(wait_for(evB, [](EventStore& s){ return s.auth_ok; }, 20000));
CHECK(wait_for(evB, [](EventStore& s){ return s.channel_list_received; }, 3000));
uint32_t bob_id = evB.self_user_id;
CHECK(bob_id != 0);
// Verify permissions reported by the core.
vc_permissions admin_perms{};
CHECK(vc_get_permissions(admin, &admin_perms) == VC_OK);
CHECK(admin_perms.is_admin != 0);
CHECK(admin_perms.can_create_temp_channel != 0);
vc_permissions bob_perms{};
CHECK(vc_get_permissions(bob, &bob_perms) == VC_OK);
CHECK(bob_perms.is_admin == 0);
CHECK(bob_perms.can_create_temp_channel == 0);
// Admin creates a channel.
vc_channel_info ch{};
ch.name = "Admin Channel";
ch.topic = "created by admin";
ch.max_users = 0;
ch.sort_order = 10;
CHECK(vc_create_channel(admin, &ch) == VC_OK);
CHECK(wait_generic(evA, 3000));
CHECK(last_generic_ok(evA));
// Normal user tries to create a channel — should be denied.
reset_generic(evB);
vc_channel_info ch2{};
ch2.name = "Bob Channel";
ch2.topic = "created by bob";
ch2.sort_order = 11;
CHECK(vc_create_channel(bob, &ch2) == VC_OK);
CHECK(wait_generic(evB, 3000));
CHECK(!last_generic_ok(evB));
// Admin grants can_create_temp_channel to bob.
reset_generic(evA);
vc_permissions grant{};
grant.can_create_temp_channel = 1;
CHECK(vc_set_permission(admin, bob_id, &grant) == VC_OK);
CHECK(wait_generic(evA, 3000));
CHECK(last_generic_ok(evA));
// Bob creates a channel — should succeed now.
reset_generic(evB);
CHECK(vc_create_channel(bob, &ch2) == VC_OK);
CHECK(wait_generic(evB, 3000));
CHECK(last_generic_ok(evB));
// Cleanup.
vc_disconnect(admin);
vc_disconnect(bob);
vc_client_destroy(admin);
vc_client_destroy(bob);
server.stop();
server_thread.join();
std::filesystem::remove_all(tmp);
if (g_failures == 0) {
std::printf("m5_permissions: all checks passed\n");
return 0;
}
std::printf("m5_permissions: %d failure(s)\n", g_failures);
return 1;
}
#else // !VOICECAT_HAS_NET
int main() {
std::printf("m5_permissions: SKIP (VOICECAT_HAS_NET not defined)\n");
return 0;
}
#endif // VOICECAT_HAS_NET

View File

@@ -29,6 +29,16 @@ struct Stats {
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below). vccli has no interactive prompt, so it // M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below). vccli has no interactive prompt, so it
// trusts-on-first-connect unconditionally (prints the fingerprint for visibility). // trusts-on-first-connect unconditionally (prints the fingerprint for visibility).
vc_client* client{nullptr}; vc_client* client{nullptr};
// M5 async result tracking. Generic results are used by every moderation/admin/channel
// request; account-list is its own event. We count generic results so callers can wait
// for a new one even if several arrived earlier.
std::atomic<int> generic_result_count{0};
std::atomic<int> last_result{0};
std::atomic<uint32_t> last_code{0};
std::string last_message;
std::mutex last_message_mu;
std::atomic<bool> account_list_received{false};
}; };
void on_event(void* user, const vc_event* ev) { void on_event(void* user, const vc_event* ev) {
@@ -48,6 +58,9 @@ void on_event(void* user, const vc_event* ev) {
std::printf("[auth] ok=%d user_id=%u %s\n", st->auth_ok.load(), ev->user_id, std::printf("[auth] ok=%d user_id=%u %s\n", st->auth_ok.load(), ev->user_id,
ev->text ? ev->text : ""); ev->text ? ev->text : "");
break; break;
case VC_EVENT_CHANNEL_LIST:
std::printf("[channel] list updated\n");
break;
case VC_EVENT_USER_JOINED: case VC_EVENT_USER_JOINED:
std::printf("[user] joined: %s (id=%u, channel=%u)\n", ev->text ? ev->text : "?", std::printf("[user] joined: %s (id=%u, channel=%u)\n", ev->text ? ev->text : "?",
ev->user_id, ev->channel_id); ev->user_id, ev->channel_id);
@@ -73,6 +86,24 @@ void on_event(void* user, const vc_event* ev) {
std::printf("[voice] talk state: user_id=%u stream_id=%u talking=%u\n", ev->user_id, std::printf("[voice] talk state: user_id=%u stream_id=%u talking=%u\n", ev->user_id,
ev->stream_id, ev->u32a); ev->stream_id, ev->u32a);
break; break;
case VC_EVENT_JOIN_RESULT:
std::printf("[join] result=%d channel=%u %s\n", ev->result, ev->channel_id,
ev->text ? ev->text : "");
break;
case VC_EVENT_GENERIC_RESULT: {
std::lock_guard lk(st->last_message_mu);
st->last_result.store(ev->result);
st->last_code.store(ev->u32a);
st->last_message = ev->text ? ev->text : "";
st->generic_result_count.fetch_add(1);
std::printf("[result] rc=%d code=%u: %s\n", ev->result, ev->u32a,
ev->text ? ev->text : "");
break;
}
case VC_EVENT_ACCOUNT_LIST:
st->account_list_received.store(true);
std::printf("[accounts] list received (use vc_list_accounts in code to inspect)\n");
break;
case VC_EVENT_ERROR: case VC_EVENT_ERROR:
std::fprintf(stderr, "[error] rc=%d: %s\n", ev->result, ev->text ? ev->text : ""); std::fprintf(stderr, "[error] rc=%d: %s\n", ev->result, ev->text ? ev->text : "");
break; break;
@@ -94,22 +125,118 @@ bool wait_until(std::atomic<bool>& flag, int timeout_ms) {
return true; return true;
} }
// Wait for a new generic result to arrive. Returns the vc_result from that result.
vc_result wait_generic_result(Stats& st, int baseline_count, int timeout_ms) {
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
while (st.generic_result_count.load() <= baseline_count) {
if (std::chrono::steady_clock::now() >= deadline) return VC_ERR_TIMEOUT;
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
return static_cast<vc_result>(st.last_result.load());
}
// Parse "true"/"false"/"1"/"0"/"yes"/"no" (case-insensitive).
bool parse_bool(const char* s, bool* out) {
if (!s || !*s) return false;
std::string v = s;
for (auto& ch : v) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
if (v == "1" || v == "true" || v == "yes" || v == "on") { *out = true; return true; }
if (v == "0" || v == "false" || v == "no" || v == "off") { *out = false; return true; }
return false;
}
bool parse_u32(const char* s, uint32_t* out, const char* label) {
if (!s || !*s) { std::fprintf(stderr, "missing value for %s\n", label); return false; }
try {
*out = static_cast<uint32_t>(std::stoul(s));
return true;
} catch (...) {
std::fprintf(stderr, "invalid value for %s: %s\n", label, s);
return false;
}
}
bool parse_u16(const char* s, uint16_t* out, const char* label) {
if (!s || !*s) { std::fprintf(stderr, "missing value for %s\n", label); return false; }
try {
int v = std::stoi(s);
if (v < 0 || v > 65535) throw std::out_of_range("port");
*out = static_cast<uint16_t>(v);
return true;
} catch (...) {
std::fprintf(stderr, "invalid value for %s: %s\n", label, s);
return false;
}
}
void print_usage() { void print_usage() {
std::printf( std::printf(
"usage: vccli [--host H] [--port P] [--nick NAME] [--channel ID]\n" "usage: vccli [--host H] [--port P] [--nick NAME | --username U --password P]\n"
" [--voice] [--mute] [--text MSG] [--list-devices]\n" " [--channel ID] [--voice] [--mute] [--text MSG] [--list-devices]\n"
" [--input-device ID] [--input-mode vad|ptt] [--share-screen-audio]\n" " [--input-device ID] [--input-mode vad|ptt] [--share-screen-audio]\n"
" [--wait-ms N]\n"
" [--kick USER_ID [--reason REASON]]\n"
" [--ban USER_ID [--reason REASON] [--ban-expires-ms MS]]\n"
" [--move USER_ID --to-channel ID]\n"
" [--server-mute USER_ID] [--server-unmute USER_ID]\n"
" [--server-deafen USER_ID] [--server-undeafen USER_ID]\n"
" [--set-permission USER_ID --perm-admin B --perm-kick B --perm-ban B\n"
" --perm-move B --perm-create-temp B --perm-admin-accounts B]\n"
" [--create-channel --new-channel-name NAME [--new-channel-topic TOPIC]\n"
" [--new-channel-parent ID] [--new-channel-password PASS]\n"
" [--new-channel-max-users N]]\n"
" [--edit-channel --channel-id ID --new-channel-name NAME\n"
" [--new-channel-topic TOPIC] [--new-channel-parent ID]\n"
" [--new-channel-password PASS] [--new-channel-max-users N]]\n"
" [--delete-channel --channel-id ID]\n"
" [--create-account USER PASS] [--reset-password USER PASS]\n"
" [--delete-account USER] [--list-accounts]\n"
" [--self-mute] [--self-deafen]\n"
"\n"
"Connection / identity:\n"
" --host H server host (default 127.0.0.1)\n" " --host H server host (default 127.0.0.1)\n"
" --port P server TCP port (default 8384)\n" " --port P server TCP port (default 8384)\n"
" --nick NAME guest nickname (default vccli-test)\n" " --nick NAME guest nickname (default vccli-test)\n"
" --username U authenticate as registered user U\n"
" --password P password for --username\n"
" --channel ID channel to join after auth (default 1, Lobby)\n" " --channel ID channel to join after auth (default 1, Lobby)\n"
" --wait-ms N timeout for M5 async result events (default 5000)\n"
"\n"
"Voice / devices:\n"
" --voice start a MIC stream and stay connected until Ctrl+C\n" " --voice start a MIC stream and stay connected until Ctrl+C\n"
" --mute start with the mic muted (only meaningful with --voice)\n" " --mute start with the mic muted (only meaningful with --voice)\n"
" --text MSG send MSG to the channel, then exit\n"
" --list-devices print input/output devices (vc_list_devices) and exit\n" " --list-devices print input/output devices (vc_list_devices) and exit\n"
" --input-device ID use device ID (from --list-devices) for the MIC stream\n" " --input-device ID use device ID (from --list-devices) for the MIC stream\n"
" --input-mode vad|ptt send-side input gate mode (default vad)\n" " --input-mode vad|ptt send-side input gate mode (default vad)\n"
" --share-screen-audio also start a SCREEN_AUDIO stream (WASAPI loopback on Windows)\n" " --share-screen-audio also start a SCREEN_AUDIO stream (WASAPI loopback on Windows)\n"
" --self-mute mute own mic before/without voice mode\n"
" --self-deafen deafen self before/without voice mode\n"
"\n"
"Text:\n"
" --text MSG send MSG to the channel, then exit\n"
"\n"
"Moderation (require permission):\n"
" --kick USER_ID [--reason REASON]\n"
" --ban USER_ID [--reason REASON] [--ban-expires-ms MS] (0 = permanent)\n"
" --move USER_ID --to-channel ID\n"
" --server-mute USER_ID, --server-unmute USER_ID\n"
" --server-deafen USER_ID, --server-undeafen USER_ID\n"
"\n"
"Permissions (require permission):\n"
" --set-permission USER_ID --perm-admin B --perm-kick B --perm-ban B\n"
" --perm-move B --perm-create-temp B --perm-admin-accounts B\n"
" B = 0|1|true|false|yes|no\n"
"\n"
"Channel management (require permission):\n"
" --create-channel --new-channel-name NAME ...\n"
" --edit-channel --channel-id ID --new-channel-name NAME ...\n"
" --delete-channel --channel-id ID\n"
"\n"
"Account management (require permission):\n"
" --create-account USER PASS\n"
" --reset-password USER PASS\n"
" --delete-account USER\n"
" --list-accounts\n"
"\n" "\n"
"While --voice is running, stdin accepts: \"ptt on\", \"ptt off\", \"mode vad\",\n" "While --voice is running, stdin accepts: \"ptt on\", \"ptt off\", \"mode vad\",\n"
"\"mode ptt\" (PTT key state can't be held interactively in a headless CLI, so it's\n" "\"mode ptt\" (PTT key state can't be held interactively in a headless CLI, so it's\n"
@@ -151,6 +278,23 @@ void run_stdin_commands(vc_client* c, std::atomic<bool>& stop) {
} }
} }
// Issue an M5 request that produces VC_EVENT_GENERIC_RESULT and wait for it.
// Returns the result code from the event.
using RequestFn = std::function<vc_result()>;
vc_result run_generic_request(Stats& st, int timeout_ms, RequestFn fn, const char* label) {
int before = st.generic_result_count.load();
vc_result r = fn();
std::printf("%s -> %d (%s)\n", label, r, vc_result_string(r));
if (r != VC_OK) return r;
vc_result event_rc = wait_generic_result(st, before, timeout_ms);
if (event_rc == VC_ERR_TIMEOUT) {
std::fprintf(stderr, "%s: timed out waiting for result event\n", label);
return VC_ERR_TIMEOUT;
}
return event_rc;
}
} // namespace } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
@@ -161,9 +305,15 @@ int main(int argc, char** argv) {
std::string host = "127.0.0.1"; std::string host = "127.0.0.1";
uint16_t port = 8384; uint16_t port = 8384;
std::string nick = "vccli-test"; std::string nick = "vccli-test";
std::string username;
std::string password;
bool have_username = false;
bool have_password = false;
uint32_t channel_id = 1; uint32_t channel_id = 1;
bool voice_mode = false; bool voice_mode = false;
bool start_muted = false; bool start_muted = false;
bool self_mute = false;
bool self_deafen = false;
std::string text_msg; std::string text_msg;
bool have_text = false; bool have_text = false;
bool list_devices = false; bool list_devices = false;
@@ -171,16 +321,60 @@ int main(int argc, char** argv) {
bool have_input_device = false; bool have_input_device = false;
vc_input_mode input_mode = VC_INPUT_VOICE_ACTIVATION; vc_input_mode input_mode = VC_INPUT_VOICE_ACTIVATION;
bool share_screen_audio = false; bool share_screen_audio = false;
int wait_ms = 5000;
// Moderation
bool do_kick = false;
uint32_t kick_user_id = 0;
std::string kick_reason;
bool do_ban = false;
uint32_t ban_user_id = 0;
std::string ban_reason;
uint64_t ban_expires_ms = 0;
bool do_move = false;
uint32_t move_user_id = 0;
uint32_t move_channel_id = 0;
bool do_server_mute = false;
bool do_server_unmute = false;
bool do_server_deafen = false;
bool do_server_undeafen = false;
uint32_t server_mute_user_id = 0;
int server_mute_muted = 0;
int server_mute_deafened = 0;
// Permissions
bool do_set_permission = false;
uint32_t perm_user_id = 0;
vc_permissions perms{};
// Channel CRUD
bool do_create_channel = false;
bool do_edit_channel = false;
bool do_delete_channel = false;
uint32_t delete_channel_id = 0;
vc_channel_info channel_info{};
// Account management
bool do_create_account = false;
bool do_reset_password = false;
bool do_delete_account = false;
bool do_list_accounts = false;
std::string acct_user;
std::string acct_pass;
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
std::string a = argv[i]; std::string a = argv[i];
auto next = [&]() -> std::string { return (i + 1 < argc) ? argv[++i] : std::string(); }; auto next = [&]() -> std::string { return (i + 1 < argc) ? argv[++i] : std::string(); };
if (a == "--host") host = next(); if (a == "--host") host = next();
else if (a == "--port") port = static_cast<uint16_t>(std::stoi(next())); else if (a == "--port") { if (!parse_u16(next().c_str(), &port, "--port")) return 1; }
else if (a == "--nick") nick = next(); else if (a == "--nick") nick = next();
else if (a == "--channel") channel_id = static_cast<uint32_t>(std::stoul(next())); else if (a == "--username") { username = next(); have_username = true; }
else if (a == "--password") { password = next(); have_password = true; }
else if (a == "--channel") { if (!parse_u32(next().c_str(), &channel_id, "--channel")) return 1; }
else if (a == "--voice") voice_mode = true; else if (a == "--voice") voice_mode = true;
else if (a == "--mute") start_muted = true; else if (a == "--mute") start_muted = true;
else if (a == "--self-mute") self_mute = true;
else if (a == "--self-deafen") self_deafen = true;
else if (a == "--text") { text_msg = next(); have_text = true; } else if (a == "--text") { text_msg = next(); have_text = true; }
else if (a == "--list-devices") list_devices = true; else if (a == "--list-devices") list_devices = true;
else if (a == "--input-device") { input_device = next(); have_input_device = true; } else if (a == "--input-device") { input_device = next(); have_input_device = true; }
@@ -190,10 +384,93 @@ int main(int argc, char** argv) {
else if (m != "vad") { std::fprintf(stderr, "--input-mode must be vad|ptt\n"); return 1; } else if (m != "vad") { std::fprintf(stderr, "--input-mode must be vad|ptt\n"); return 1; }
} }
else if (a == "--share-screen-audio") share_screen_audio = true; else if (a == "--share-screen-audio") share_screen_audio = true;
else if (a == "--wait-ms") {
std::string v = next();
try { wait_ms = std::stoi(v); } catch (...) {
std::fprintf(stderr, "invalid --wait-ms: %s\n", v.c_str()); return 1;
}
if (wait_ms < 0) wait_ms = 0;
}
// Moderation
else if (a == "--kick") { do_kick = true; if (!parse_u32(next().c_str(), &kick_user_id, "--kick")) return 1; }
else if (a == "--reason") {
std::string r = next();
if (do_kick) kick_reason = r;
else if (do_ban) ban_reason = r;
else { std::fprintf(stderr, "--reason without --kick or --ban\n"); return 1; }
}
else if (a == "--ban") { do_ban = true; if (!parse_u32(next().c_str(), &ban_user_id, "--ban")) return 1; }
else if (a == "--ban-expires-ms") {
std::string v = next();
try { ban_expires_ms = static_cast<uint64_t>(std::stoull(v)); } catch (...) {
std::fprintf(stderr, "invalid --ban-expires-ms: %s\n", v.c_str()); return 1;
}
}
else if (a == "--move") { do_move = true; if (!parse_u32(next().c_str(), &move_user_id, "--move")) return 1; }
else if (a == "--to-channel") { if (!parse_u32(next().c_str(), &move_channel_id, "--to-channel")) return 1; }
else if (a == "--server-mute") { do_server_mute = true; server_mute_muted = 1; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-mute")) return 1; }
else if (a == "--server-unmute") { do_server_unmute = true; server_mute_muted = 0; server_mute_deafened = 0; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-unmute")) return 1; }
else if (a == "--server-deafen") { do_server_deafen = true; server_mute_muted = 1; server_mute_deafened = 1; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-deafen")) return 1; }
else if (a == "--server-undeafen") { do_server_undeafen = true; server_mute_muted = 0; server_mute_deafened = 0; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-undeafen")) return 1; }
// Permissions
else if (a == "--set-permission") { do_set_permission = true; if (!parse_u32(next().c_str(), &perm_user_id, "--set-permission")) return 1; }
else if (a == "--perm-admin") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.is_admin = b; }
else if (a == "--perm-kick") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_kick = b; }
else if (a == "--perm-ban") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_ban = b; }
else if (a == "--perm-move") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_move_users = b; }
else if (a == "--perm-create-temp") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_create_temp_channel = b; }
else if (a == "--perm-admin-accounts") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_admin_accounts = b; }
// Channel CRUD
else if (a == "--create-channel") do_create_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 == "--channel-id") { if (!parse_u32(next().c_str(), &channel_info.id, "--channel-id")) return 1; }
else if (a == "--new-channel-name") channel_info.name = next().c_str();
else if (a == "--new-channel-topic") channel_info.topic = next().c_str();
else if (a == "--new-channel-parent") { if (!parse_u32(next().c_str(), &channel_info.parent_id, "--new-channel-parent")) return 1; }
else if (a == "--new-channel-password") {
channel_info.password = next().c_str();
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; }
// Account management
else if (a == "--create-account") { do_create_account = true; acct_user = next(); acct_pass = next(); }
else if (a == "--reset-password") { do_reset_password = true; acct_user = next(); acct_pass = next(); }
else if (a == "--delete-account") { do_delete_account = true; acct_user = next(); }
else if (a == "--list-accounts") do_list_accounts = true;
else if (a == "--help" || a == "-h") { print_usage(); return 0; } else if (a == "--help" || a == "-h") { print_usage(); return 0; }
else { std::fprintf(stderr, "unknown flag: %s\n", a.c_str()); print_usage(); return 1; } else { std::fprintf(stderr, "unknown flag: %s\n", a.c_str()); print_usage(); return 1; }
} }
// Validate auth mode.
if (have_username != have_password) {
std::fprintf(stderr, "--username and --password must be used together\n");
return 1;
}
// Validate moderation flags that need extra args.
if (do_move && move_channel_id == 0) {
std::fprintf(stderr, "--move requires --to-channel\n");
return 1;
}
if ((do_server_mute || do_server_unmute || do_server_deafen || do_server_undeafen) &&
server_mute_user_id == 0) {
std::fprintf(stderr, "server mute/deafen requires a user id\n");
return 1;
}
// Normalize server mute/deafen into a single request.
bool do_server_mute_request = do_server_mute || do_server_unmute || do_server_deafen || do_server_undeafen;
// Validate channel CRUD.
if (do_create_channel && (!channel_info.name || !*channel_info.name)) {
std::fprintf(stderr, "--create-channel requires --new-channel-name\n");
return 1;
}
if (do_edit_channel && (channel_info.id == 0 || !channel_info.name || !*channel_info.name)) {
std::fprintf(stderr, "--edit-channel requires --channel-id and --new-channel-name\n");
return 1;
}
std::printf("vccli — VoiceCat test client (core %s, protocol v%d)\n", vc_version_string(), std::printf("vccli — VoiceCat test client (core %s, protocol v%d)\n", vc_version_string(),
VOICECAT_PROTOCOL_VERSION); VOICECAT_PROTOCOL_VERSION);
@@ -231,8 +508,15 @@ int main(int argc, char** argv) {
return 1; return 1;
} }
if (have_username) {
r = vc_authenticate_user(c, username.c_str(), password.c_str());
std::printf("vc_authenticate_user(%s) -> %d (%s)\n", username.c_str(), r,
vc_result_string(r));
} else {
r = vc_authenticate_guest(c, nick.c_str()); r = vc_authenticate_guest(c, nick.c_str());
std::printf("vc_authenticate_guest(%s) -> %d (%s)\n", nick.c_str(), r, vc_result_string(r)); std::printf("vc_authenticate_guest(%s) -> %d (%s)\n", nick.c_str(), r,
vc_result_string(r));
}
if (!wait_until(st.auth_done, 8000) || !st.auth_ok.load()) { if (!wait_until(st.auth_done, 8000) || !st.auth_ok.load()) {
std::fprintf(stderr, "authentication failed or timed out\n"); std::fprintf(stderr, "authentication failed or timed out\n");
@@ -252,6 +536,112 @@ int main(int argc, char** argv) {
std::this_thread::sleep_for(std::chrono::milliseconds(300)); // let the relay land std::this_thread::sleep_for(std::chrono::milliseconds(300)); // let the relay land
} }
// M5 moderation / admin requests (executed in a sensible order if multiple are given).
bool m5_error = false;
if (self_mute || self_deafen) {
r = vc_set_self_mute(c, self_mute ? 1 : 0, self_deafen ? 1 : 0);
std::printf("vc_set_self_mute -> %d (%s)\n", r, vc_result_string(r));
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_kick) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_kick_user(c, kick_user_id, kick_reason.c_str()); },
"vc_kick_user");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_ban) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_ban_user(c, ban_user_id, ban_reason.c_str(), ban_expires_ms); },
"vc_ban_user");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_move) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_move_user(c, move_user_id, move_channel_id); },
"vc_move_user");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_server_mute_request) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_set_server_mute(c, server_mute_user_id, server_mute_muted, server_mute_deafened); },
"vc_set_server_mute");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_set_permission) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_set_permission(c, perm_user_id, &perms); },
"vc_set_permission");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_create_channel) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_create_channel(c, &channel_info); },
"vc_create_channel");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_edit_channel) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_edit_channel(c, &channel_info); },
"vc_edit_channel");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_delete_channel) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_delete_channel(c, delete_channel_id); },
"vc_delete_channel");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_create_account) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_create_account(c, acct_user.c_str(), acct_pass.c_str()); },
"vc_create_account");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_reset_password) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_reset_password(c, acct_user.c_str(), acct_pass.c_str()); },
"vc_reset_password");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_delete_account) {
r = run_generic_request(st, wait_ms,
[&]() { return vc_delete_account(c, acct_user.c_str()); },
"vc_delete_account");
if (r != VC_OK) m5_error = true;
}
if (!m5_error && do_list_accounts) {
st.account_list_received.store(false);
r = vc_list_accounts(c);
std::printf("vc_list_accounts -> %d (%s)\n", r, vc_result_string(r));
if (r != VC_OK) {
m5_error = true;
} else {
if (!wait_until(st.account_list_received, wait_ms)) {
std::fprintf(stderr, "vc_list_accounts: timed out waiting for list event\n");
m5_error = true;
}
}
}
if (m5_error && !voice_mode) {
vc_disconnect(c);
vc_client_destroy(c);
return 1;
}
if (voice_mode) { if (voice_mode) {
// Give the async UDP binding handshake (TCP UdpBinding -> ack -> plaintext // Give the async UDP binding handshake (TCP UdpBinding -> ack -> plaintext
// bootstrap packet) a moment to land before announcing a stream. // bootstrap packet) a moment to land before announcing a stream.
@@ -315,5 +705,5 @@ int main(int argc, char** argv) {
vc_disconnect(c); vc_disconnect(c);
vc_client_destroy(c); vc_client_destroy(c);
std::printf("ok\n"); std::printf("ok\n");
return 0; return m5_error ? 1 : 0;
} }