diff --git a/PROGRESS.md b/PROGRESS.md index 407796c..8a98f6d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,18 @@ up instantly. Newest status at the top. ## ▶ 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 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). @@ -92,8 +104,8 @@ up instantly. Newest status at the top. - [x] **M1 — Control plane** ✓ complete (2026-06-15) - [x] **M2 — Voice, single stream** ✓ 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 -- [ ] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …) +- [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS/iOS Swift pending +- [~] **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 All architecture/scope decisions are settled and recorded in diff --git a/core/include/voicecat.h b/core/include/voicecat.h index c9e3e51..27b1911 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -127,7 +127,7 @@ typedef enum vc_event_type { VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */ VC_EVENT_ERROR = 10, /* result, text */ 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 failure. Reply to vc_join_channel(). */ 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 connection is held open until vc_confirm_server_identity() 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; /* 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 */ } 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 { const char* id; 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, 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) } /* extern "C" */ #endif diff --git a/core/proto/voicecat.proto b/core/proto/voicecat.proto index 30b7d78..c0a6bf8 100644 --- a/core/proto/voicecat.proto +++ b/core/proto/voicecat.proto @@ -54,12 +54,14 @@ message Envelope { KickRequest kick = 60; BanRequest ban = 61; SetPermissionRequest set_permission = 62; + ServerMuteRequest server_mute = 63; // Admin account management — privileged; accounts are admin-provisioned (70–79) CreateAccountRequest create_account = 70; ResetPasswordRequest reset_password = 71; DeleteAccountRequest delete_account = 72; ListAccountsRequest list_accounts = 73; + ListAccountsResult list_accounts_result = 74; // Future families: file transfer = 100–109. Extension escape hatch = 200+. Extension extension = 200; @@ -116,6 +118,7 @@ message User { bool self_deafened = 6; bool server_muted = 7; repeated StreamInfo streams = 8; + bool server_deafened = 9; // M5: server-imposed deafen } message Permissions { @@ -186,6 +189,7 @@ message UserEvent { Kind kind = 1; User user = 2; uint32 left_id = 3; + string reason = 4; // M5: kick/ban reason for LEFT events } 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 BanRequest { uint32 user_id = 1; string reason = 2; uint64 expires_unix_ms = 3; } 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) ──────────────────────────────────────── message CreateAccountRequest { string username = 1; string password = 2; } message ResetPasswordRequest { string username = 1; string new_password = 2; } message DeleteAccountRequest { string username = 1; } 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 ─────────────────────────────────────────────────────── message Extension { string ns = 1; bytes payload = 2; } diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index f64ae67..de2ab2a 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -309,7 +309,7 @@ void vc_client::run_io(std::string host, uint16_t port) { 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: #ifdef _WIN32 @@ -415,6 +415,21 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) { break; case voicecat::v1::Envelope::kPong: 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: break; } @@ -468,6 +483,15 @@ void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) { ev.user_id = self_user_id_; 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(); if (tok.size() == 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: ev.type = VC_EVENT_USER_UPDATED; 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); break; default: @@ -776,6 +812,36 @@ voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::Au p.application = static_cast(a.application()); 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(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(c.sort_order)); + return ch; +} + } // namespace 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; // "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). + // M5: server-mute is also a hard gate on MIC transmission. if (kind == static_cast(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 // 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()); 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()); } @@ -1111,9 +1181,10 @@ vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) { self_deafened_.store(deafened, std::memory_order_release); std::lock_guard lk(remote_streams_mu_); + bool muted = deafened || server_deafened_.load(std::memory_order_acquire); for (auto& [ssrc, info] : remote_streams_) { (void)info; - audio_engine_.set_stream_mute(ssrc, deafened); + audio_engine_.set_stream_mute(ssrc, muted); } 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; } +// ── 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() { while (!talk_timer_stop_.load(std::memory_order_acquire)) { // 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; 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 diff --git a/core/src/core/client.h b/core/src/core/client.h index d535d37..6196f8d 100644 --- a/core/src/core/client.h +++ b/core/src/core/client.h @@ -77,6 +77,21 @@ struct vc_client { // 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); + // 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 { #ifdef VOICECAT_HAS_NET return state_net_.load(std::memory_order_acquire); @@ -202,6 +217,11 @@ struct vc_client { std::atomic self_mic_muted_{false}; std::atomic self_deafened_{false}; + std::atomic server_muted_{false}; + std::atomic 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/ // AUX_DEVICE are never gated (see PROGRESS.md for the rationale). Pure local state, no diff --git a/core/src/session/session.cpp b/core/src/session/session.cpp index 6e15738..42fc245 100644 --- a/core/src/session/session.cpp +++ b/core/src/session/session.cpp @@ -66,11 +66,13 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) users_.clear(); for (const auto& pb : snap.users()) { User u; - u.id = pb.id(); - u.nickname = pb.nickname(); - u.is_guest = pb.is_guest(); - u.channel_id = pb.channel_id(); - u.streams = copy_streams(pb.streams()); + u.id = pb.id(); + u.nickname = pb.nickname(); + u.is_guest = pb.is_guest(); + u.channel_id = pb.channel_id(); + u.server_muted = pb.server_muted(); + u.server_deafened = pb.server_deafened(); + u.streams = copy_streams(pb.streams()); users_.push_back(std::move(u)); } } @@ -81,11 +83,13 @@ void SessionModel::apply_user_event(const voicecat::v1::UserEvent& ev) { if (ev.kind() == Kind::JOINED || ev.kind() == Kind::UPDATED) { const auto& pb = ev.user(); User u; - u.id = pb.id(); - u.nickname = pb.nickname(); - u.is_guest = pb.is_guest(); - u.channel_id = pb.channel_id(); - u.streams = copy_streams(pb.streams()); + u.id = pb.id(); + u.nickname = pb.nickname(); + u.is_guest = pb.is_guest(); + u.channel_id = pb.channel_id(); + u.server_muted = pb.server_muted(); + u.server_deafened = pb.server_deafened(); + u.streams = copy_streams(pb.streams()); auto it = std::find_if(users_.begin(), users_.end(), [&](const User& x) { return x.id == u.id; }); diff --git a/core/src/session/session.h b/core/src/session/session.h index 521bc59..d7598dc 100644 --- a/core/src/session/session.h +++ b/core/src/session/session.h @@ -51,6 +51,8 @@ struct User { std::string nickname; bool is_guest{true}; uint32_t channel_id{0}; + bool server_muted{false}; + bool server_deafened{false}; std::vector streams; }; diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp index 32aef3f..1d885fb 100644 --- a/core/src/voicecat.cpp +++ b/core/src/voicecat.cpp @@ -202,9 +202,75 @@ vc_result vc_confirm_server_identity(vc_client* c, int accept) { } 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) { if (c == nullptr) return VC_ERR_INVALID_ARG; 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" diff --git a/docs/protocol.md b/docs/protocol.md index 54e7938..54ffcaf 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -92,6 +92,7 @@ message Envelope { KickRequest kick = 60; BanRequest ban = 61; SetPermissionRequest set_permission = 62; + ServerMuteRequest server_mute = 63; // ── Admin account management (privileged) ───────────── // Accounts are admin-provisioned (no self-serve registration in v1). @@ -100,6 +101,7 @@ message Envelope { ResetPasswordRequest reset_password = 71; DeleteAccountRequest delete_account = 72; ListAccountsRequest list_accounts = 73; + ListAccountsResult list_accounts_result = 74; // ── Extension escape hatch ──────────────────────────── Extension extension = 200; // {string ns; bytes payload;} @@ -224,6 +226,7 @@ message User { bool self_deafened = 6; bool server_muted = 7; repeated StreamInfo streams = 8; // active media streams this user publishes + bool server_deafened = 9; // M5: server-imposed deafen } message StreamInfo { @@ -268,7 +271,8 @@ message TextMessage { server→client events use `request_id = 0`. - **`GenericResult { bool ok; uint32 code; string message; }`** is the default 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. ## 7. Keepalive & timeouts diff --git a/docs/security.md b/docs/security.md index e34583a..2156ac2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -117,7 +117,7 @@ Binding works as: 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 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 exponential backoff; configurable lockout. Generic `auth_request` failures return a non-enumerating error ("invalid credentials") to avoid username probing. @@ -126,7 +126,7 @@ Binding works as: accounts( id INTEGER PK, username TEXT UNIQUE, pw_argon2id TEXT, -- encoded hash incl. params + salt 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) diff --git a/server/src/conn_session.cpp b/server/src/conn_session.cpp index 335b85e..5a433d5 100644 --- a/server/src/conn_session.cpp +++ b/server/src/conn_session.cpp @@ -22,6 +22,22 @@ static voicecat::v1::Envelope make_env(uint64_t req_id = 0) { 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 db, std::shared_ptr registry, std::shared_ptr workers, @@ -73,7 +89,7 @@ void ConnSession::on_frame(std::vector frame) { break; case voicecat::v1::Envelope::kLeaveChannel: if (st == State::Authenticated) - registry_->set_user_channel(user_id_.load(), 1); + handle_leave_channel(); break; case voicecat::v1::Envelope::kUdpBinding: if (st == State::Authenticated) @@ -87,6 +103,54 @@ void ConnSession::on_frame(std::vector frame) { if (st == State::Authenticated) handle_stream_stop(env.stream_stop()); 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: break; } @@ -149,6 +213,27 @@ asio::ip::udp::endpoint ConnSession::udp_endpoint() const { 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 ───────────────────────────────────────────────────────────────── 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); return; } + voicecat::v1::User user; user.set_nickname(guest.nickname().empty() ? "Guest" : guest.nickname()); 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); 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_); { @@ -208,6 +296,7 @@ void ConnSession::finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64 res->set_ok(true); res->set_session_id(session_id_); *res->mutable_self() = user; + *res->mutable_permissions() = permissions_; res->set_udp_token(udp_token_.data(), udp_token_.size()); send_envelope(env); } @@ -220,6 +309,15 @@ void ConnSession::finish_password_auth(const std::string& username, // Argon2id runs on the worker pool (deliberately slow). auto self = shared_from_this(); 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); if (!acc) { 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->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_); { @@ -246,8 +347,7 @@ void ConnSession::finish_password_auth(const std::string& username, res->set_ok(true); res->set_session_id(self->session_id_); *res->mutable_self() = user; - auto* perms = res->mutable_permissions(); - perms->set_is_admin(acc->is_admin); + *res->mutable_permissions() = perms; res->set_udp_token(self->udp_token_.data(), self->udp_token_.size()); 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, 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* res = env.mutable_join_channel_result(); res->set_ok(ok); - if (!ok) res->set_error("channel not found"); - else res->set_channel_id(msg.channel_id()); + if (!ok) { + 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); } +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) { using namespace std::chrono; int64_t now_ms = duration_cast( @@ -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(msg.expires_unix_ms()), err); + } + } + + bool ok = registry_->ban_user(msg.user_id(), msg.reason(), + static_cast(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(acc.created_at) * 1000); + e->set_last_login_unix_ms(static_cast(acc.last_login) * 1000); + } + send_envelope(env); +} + void ConnSession::send_disconnect_and_close(uint32_t code, const std::string& reason) { auto env = make_env(); auto* d = env.mutable_disconnect(); diff --git a/server/src/conn_session.h b/server/src/conn_session.h index 31b6107..a5e56b0 100644 --- a/server/src/conn_session.h +++ b/server/src/conn_session.h @@ -57,6 +57,10 @@ class ConnSession : public std::enable_shared_from_this { void send_envelope(const voicecat::v1::Envelope& env); 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) ─────────────────── void set_media_crypto(std::unique_ptr send, std::unique_ptr recv); @@ -87,6 +91,21 @@ class ConnSession : public std::enable_shared_from_this { 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_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_password_auth(const std::string& username, const std::string& password, @@ -95,7 +114,13 @@ class ConnSession : public std::enable_shared_from_this { const voicecat::v1::Permissions* perms = nullptr); void send_state_snapshot(); 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 db_; std::shared_ptr registry_; @@ -126,6 +151,9 @@ class ConnSession : public std::enable_shared_from_this { // support multiple concurrent streams (MIC + SCREEN_AUDIO + AUX_DEVICE) per user. uint32_t next_stream_id_{1}; std::vector announced_stream_ids_; + + // M5: permissions granted at auth time (server-side authority). + voicecat::v1::Permissions permissions_; }; } // namespace voicecat::server diff --git a/server/src/db.cpp b/server/src/db.cpp index 6d6b872..a7efb29 100644 --- a/server/src/db.cpp +++ b/server/src/db.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -13,7 +14,9 @@ namespace voicecat::server { // ── 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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, @@ -26,7 +29,38 @@ CREATE TABLE IF NOT EXISTS server_meta ( key TEXT PRIMARY KEY, 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"; // ── Database ────────────────────────────────────────────────────────────────── @@ -50,7 +84,37 @@ bool Database::open(std::string& error) { exec("PRAGMA journal_mode=WAL", error); exec("PRAGMA synchronous=NORMAL", error); 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(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; } @@ -63,6 +127,8 @@ bool Database::is_empty() { return count == 0; } +// ── Accounts ────────────────────────────────────────────────────────────────── + std::optional Database::create_account(const std::string& username, const std::string& password, bool is_admin, std::string& error) { @@ -108,6 +174,7 @@ std::optional Database::create_account(const std::string& username, bool Database::reset_password(const std::string& username, const std::string& new_password, std::string& error) { + if (new_password.empty()) { error = "password must not be empty"; return false; } char hash[crypto_pwhash_STRBYTES]; if (crypto_pwhash_str(hash, new_password.c_str(), new_password.size(), crypto_pwhash_OPSLIMIT_INTERACTIVE, @@ -194,6 +261,326 @@ std::optional Database::authenticate(const std::string& username, 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(a.codec())); + sqlite3_bind_int(stmt, base_idx + 1, static_cast(a.mode())); + sqlite3_bind_int(stmt, base_idx + 2, static_cast(a.sample_rate())); + sqlite3_bind_int(stmt, base_idx + 3, static_cast(a.bitrate_bps())); + sqlite3_bind_int(stmt, base_idx + 4, static_cast(a.frame_ms())); + sqlite3_bind_int(stmt, base_idx + 5, static_cast(a.application())); + sqlite3_bind_int(stmt, base_idx + 6, a.fec() ? 1 : 0); + sqlite3_bind_int(stmt, base_idx + 7, static_cast(a.expected_packet_loss())); + sqlite3_bind_int(stmt, base_idx + 8, a.dtx() ? 1 : 0); + sqlite3_bind_int(stmt, base_idx + 9, static_cast(a.complexity())); +} + +voicecat::v1::AudioConfig read_audio(sqlite3_stmt* stmt, int base_idx) { + voicecat::v1::AudioConfig a; + a.set_codec(static_cast(sqlite3_column_int(stmt, base_idx + 0))); + a.set_mode(static_cast(sqlite3_column_int(stmt, base_idx + 1))); + a.set_sample_rate(static_cast(sqlite3_column_int(stmt, base_idx + 2))); + a.set_bitrate_bps(static_cast(sqlite3_column_int(stmt, base_idx + 3))); + a.set_frame_ms(static_cast(sqlite3_column_int(stmt, base_idx + 4))); + a.set_application(static_cast(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(sqlite3_column_int(stmt, base_idx + 7))); + a.set_dtx(sqlite3_column_int(stmt, base_idx + 8) != 0); + a.set_complexity(static_cast(sqlite3_column_int(stmt, base_idx + 9))); + return a; +} + +} // namespace + +std::optional 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(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(ch.max_users)); + sqlite3_bind_int(stmt, 6, static_cast(ch.type)); + bind_audio(stmt, 7, ch.audio); // parameters 7-16 + sqlite3_bind_int(stmt, 17, static_cast(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(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(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(ch.max_users)); + sqlite3_bind_int(stmt, idx++, static_cast(ch.type)); + bind_audio(stmt, idx, ch.audio); idx += 10; + sqlite3_bind_int(stmt, idx++, static_cast(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(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(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 Database::list_channels() { + std::vector 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(sqlite3_column_int(stmt, 0)); + ch.parent_id = static_cast(sqlite3_column_int(stmt, 1)); + ch.name = reinterpret_cast(sqlite3_column_text(stmt, 2)); + ch.topic = reinterpret_cast(sqlite3_column_text(stmt, 3)); + ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0'; + ch.max_users = static_cast(sqlite3_column_int(stmt, 5)); + ch.type = static_cast(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 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(id)); + std::optional result; + if (sqlite3_step(stmt) == SQLITE_ROW) { + ChannelRecord ch; + ch.id = static_cast(sqlite3_column_int(stmt, 0)); + ch.parent_id = static_cast(sqlite3_column_int(stmt, 1)); + ch.name = reinterpret_cast(sqlite3_column_text(stmt, 2)); + ch.topic = reinterpret_cast(sqlite3_column_text(stmt, 3)); + ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0'; + ch.max_users = static_cast(sqlite3_column_int(stmt, 5)); + ch.type = static_cast(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(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(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(id)); + bool ok = false; + if (sqlite3_step(stmt) == SQLITE_ROW) { + const char* stored = reinterpret_cast(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 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 Database::ban_list(bool include_expired) { + std::vector 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(sqlite3_column_text(stmt, 1)); + b.subject = reinterpret_cast(sqlite3_column_text(stmt, 2)); + b.reason = reinterpret_cast(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) { static const char kAlphabet[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*"; diff --git a/server/src/db.h b/server/src/db.h index 228ae9c..4061b58 100644 --- a/server/src/db.h +++ b/server/src/db.h @@ -16,6 +16,8 @@ #include #include +#include "proto/voicecat.pb.h" + struct sqlite3; namespace voicecat::server { @@ -28,6 +30,29 @@ struct Account { 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 { public: explicit Database(std::string path); @@ -43,6 +68,8 @@ class Database { // True if the accounts table has no rows. bool is_empty(); + // ── Accounts ─────────────────────────────────────────────────────────────── + // Create a new account. Hashes password with Argon2id. Thread-safe. std::optional create_account(const std::string& username, const std::string& password, @@ -63,13 +90,62 @@ class Database { std::optional authenticate(const std::string& username, 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 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 list_channels(); + + // Fetch a single channel. Thread-safe. + std::optional 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 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 ban_list(bool include_expired = false); + // Generate a random printable password of the given length. static std::string generate_password(size_t length = 20); private: bool exec(const std::string& sql, std::string& error); + bool migrate(std::string& error); 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_; sqlite3* db_{nullptr}; }; diff --git a/server/src/server.cpp b/server/src/server.cpp index 77f7230..0af91a1 100644 --- a/server/src/server.cpp +++ b/server/src/server.cpp @@ -54,8 +54,8 @@ int Server::run() { } // ── Session registry ───────────────────────────────────────────────────── - auto registry = std::make_shared(); - registry->init_default_channels(); + auto registry = std::make_shared(db); + registry->load_channels(); // ── Worker pool ────────────────────────────────────────────────────────── auto workers = std::make_shared(3); diff --git a/server/src/session_registry.cpp b/server/src/session_registry.cpp index 903ed79..6868d92 100644 --- a/server/src/session_registry.cpp +++ b/server/src/session_registry.cpp @@ -10,54 +10,82 @@ namespace voicecat::server { -void SessionRegistry::init_default_channels() { +SessionRegistry::SessionRegistry(std::shared_ptr db) : db_(std::move(db)) {} + +void SessionRegistry::load_channels() { std::unique_lock lk(mu_); - ChannelEntry lobby; - 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); + channels_.clear(); - ChannelEntry music; - music.proto.set_id(2); - music.proto.set_name("Music Room"); - music.proto.set_type(voicecat::v1::CHANNEL_PERMANENT); - music.proto.set_order(1); - { - // 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); + auto records = db_->list_channels(); + if (records.empty()) { + // First run: seed the default channel tree. + seed_default_channels(); + records = db_->list_channels(); } - 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 session) { @@ -70,6 +98,7 @@ uint64_t SessionRegistry::register_session(std::weak_ptr session) { void SessionRegistry::unregister_session(uint64_t session_id) { std::unique_lock lk(mu_); sessions_.erase(session_id); + session_permissions_.erase(session_id); } uint32_t SessionRegistry::add_user(uint64_t session_id, const voicecat::v1::User& user) { @@ -115,6 +144,20 @@ std::vector SessionRegistry::user_snapshot() const { return result; } +std::optional 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 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> SessionRegistry::resolve_text_targets( uint64_t sender_session_id, voicecat::v1::TextScope scope, uint32_t target_id) const { std::shared_lock lk(mu_); @@ -145,13 +188,220 @@ std::vector> SessionRegistry::resolve_text_targets( void SessionRegistry::broadcast(const voicecat::v1::Envelope& env, uint64_t exclude_session_id) const { 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_) { if (sid == exclude_session_id) continue; 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 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 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 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& token, uint64_t session_id) { @@ -170,7 +420,7 @@ std::shared_ptr SessionRegistry::find_by_udp_token( } void SessionRegistry::register_udp_endpoint(asio::ip::udp::endpoint ep, - uint64_t session_id) { + uint64_t session_id) { std::unique_lock lk(mu_); udp_endpoints_[ep] = session_id; } @@ -223,7 +473,7 @@ std::optional SessionRegistry::set_user_stream( } std::optional SessionRegistry::clear_user_stream(uint32_t user_id, - uint32_t stream_id) { + uint32_t stream_id) { std::unique_lock lk(mu_); auto it = users_.find(user_id); if (it == users_.end()) return std::nullopt; diff --git a/server/src/session_registry.h b/server/src/session_registry.h index 4d72a86..1398d5c 100644 --- a/server/src/session_registry.h +++ b/server/src/session_registry.h @@ -23,6 +23,7 @@ #define ASIO_STANDALONE 1 #include +#include "db.h" #include "proto/voicecat.pb.h" namespace voicecat::server { @@ -48,10 +49,10 @@ struct UdpEndpointHash { class SessionRegistry { public: - SessionRegistry() = default; + explicit SessionRegistry(std::shared_ptr db); - // Create the default "Lobby" channel (id=1, permanent). Call once at startup. - void init_default_channels(); + // Load channels from the database, seeding defaults on first run. + void load_channels(); // Register a session (before auth). Returns the assigned session_id. uint64_t register_session(std::weak_ptr session); @@ -71,6 +72,8 @@ class SessionRegistry { // Snapshot for ServerStateSnapshot message. std::vector channel_snapshot() const; std::vector user_snapshot() const; + std::optional user_snapshot_user(uint32_t user_id) const; + std::optional user_nickname(uint32_t user_id) const; // Resolve target sessions for a text message relay. std::vector> resolve_text_targets( @@ -79,7 +82,56 @@ class SessionRegistry { // Broadcast an envelope to all sessions except the excluded one. 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 get_session_permissions( + uint64_t session_id) const; + + // ── Moderation ───────────────────────────────────────────────────────────── + + // Find a live session by its user_id. Returns nullptr if offline. + std::shared_ptr 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 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). void register_udp_token(const std::array& token, uint64_t session_id); @@ -99,7 +151,7 @@ class SessionRegistry { // Add/replace a stream entry on a user (called when StreamAnnounce succeeds). // Returns the updated User proto for broadcasting, or nullopt if user not found. std::optional set_user_stream(uint32_t user_id, - const voicecat::v1::StreamInfo& info); + const voicecat::v1::StreamInfo& info); // Remove a stream entry from a user (called on StreamStop). Returns the updated // User proto for broadcasting, or nullopt if user not found. @@ -119,15 +171,20 @@ class SessionRegistry { std::optional channel_audio_config(uint32_t channel_id) const; private: + void seed_default_channels(); + mutable std::shared_mutex mu_; + std::shared_ptr db_; + uint64_t next_session_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> sessions_; std::unordered_map users_; std::unordered_map channels_; + std::unordered_map session_permissions_; // M2: token → session_id (populated at auth, cleared on disconnect) struct TokenHash { @@ -150,5 +207,5 @@ class SessionRegistry { } // namespace voicecat::server -#endif // VOICECAT_HAS_NET +#endif // VOICECAT_SERVER_SESSION_REGISTRY_H #endif // VOICECAT_SERVER_SESSION_REGISTRY_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 45f5b83..323ea5c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -113,4 +113,36 @@ if(VOICECAT_USE_VCPKG_DEPS) target_include_directories(test_tofu_flow PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) add_test(NAME tofu_flow COMMAND test_tofu_flow) 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() diff --git a/tests/test_m5_admin_accounts.cpp b/tests/test_m5_admin_accounts.cpp new file mode 100644 index 0000000..4f8791c --- /dev/null +++ b/tests/test_m5_admin_accounts.cpp @@ -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 +#include + +#ifdef VOICECAT_HAS_NET + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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(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 +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 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 diff --git a/tests/test_m5_channel_crud.cpp b/tests/test_m5_channel_crud.cpp new file mode 100644 index 0000000..15a28b1 --- /dev/null +++ b/tests/test_m5_channel_crud.cpp @@ -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 +#include + +#ifdef VOICECAT_HAS_NET + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 generic_results; + + vc_client* client{nullptr}; + const char* label{nullptr}; +}; + +static void on_event(void* user, const vc_event* ev) { + auto* s = static_cast(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 +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(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 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 diff --git a/tests/test_m5_kick_ban_move_mute.cpp b/tests/test_m5_kick_ban_move_mute.cpp new file mode 100644 index 0000000..b723993 --- /dev/null +++ b/tests/test_m5_kick_ban_move_mute.cpp @@ -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 +#include + +#ifdef VOICECAT_HAS_NET + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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(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(ev->result); + s->disconnect_text = ev->text ? ev->text : ""; + break; + default: + break; + } + s->cv.notify_all(); +} + +template +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 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 diff --git a/tests/test_m5_permissions.cpp b/tests/test_m5_permissions.cpp new file mode 100644 index 0000000..7ddce6f --- /dev/null +++ b/tests/test_m5_permissions.cpp @@ -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 +#include + +#ifdef VOICECAT_HAS_NET + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 generic_results; + + vc_client* client{nullptr}; + const char* label{nullptr}; +}; + +static void on_event(void* user, const vc_event* ev) { + auto* s = static_cast(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 +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 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 diff --git a/tools/vccli/src/main.cpp b/tools/vccli/src/main.cpp index f2277d9..daaf2f2 100644 --- a/tools/vccli/src/main.cpp +++ b/tools/vccli/src/main.cpp @@ -23,12 +23,22 @@ std::atomic g_stop{false}; void on_sigint(int) { g_stop.store(true); } struct Stats { - std::atomic auth_done{false}; - std::atomic auth_ok{false}; + std::atomic auth_done{false}; + std::atomic auth_ok{false}; // Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the // 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). 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 generic_result_count{0}; + std::atomic last_result{0}; + std::atomic last_code{0}; + std::string last_message; + std::mutex last_message_mu; + std::atomic account_list_received{false}; }; 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, ev->text ? ev->text : ""); break; + case VC_EVENT_CHANNEL_LIST: + std::printf("[channel] list updated\n"); + break; case VC_EVENT_USER_JOINED: std::printf("[user] joined: %s (id=%u, channel=%u)\n", ev->text ? ev->text : "?", 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, ev->stream_id, ev->u32a); 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: std::fprintf(stderr, "[error] rc=%d: %s\n", ev->result, ev->text ? ev->text : ""); break; @@ -94,22 +125,118 @@ bool wait_until(std::atomic& flag, int timeout_ms) { 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(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(std::tolower(static_cast(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(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(v); + return true; + } catch (...) { + std::fprintf(stderr, "invalid value for %s: %s\n", label, s); + return false; + } +} + void print_usage() { std::printf( - "usage: vccli [--host H] [--port P] [--nick NAME] [--channel ID]\n" - " [--voice] [--mute] [--text MSG] [--list-devices]\n" + "usage: vccli [--host H] [--port P] [--nick NAME | --username U --password P]\n" + " [--channel ID] [--voice] [--mute] [--text MSG] [--list-devices]\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" " --port P server TCP port (default 8384)\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" + " --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" " --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" " --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" " --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" "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" @@ -151,6 +278,23 @@ void run_stdin_commands(vc_client* c, std::atomic& 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 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 int main(int argc, char** argv) { @@ -161,9 +305,15 @@ int main(int argc, char** argv) { std::string host = "127.0.0.1"; uint16_t port = 8384; std::string nick = "vccli-test"; + std::string username; + std::string password; + bool have_username = false; + bool have_password = false; uint32_t channel_id = 1; bool voice_mode = false; bool start_muted = false; + bool self_mute = false; + bool self_deafen = false; std::string text_msg; bool have_text = false; bool list_devices = false; @@ -171,16 +321,60 @@ int main(int argc, char** argv) { bool have_input_device = false; vc_input_mode input_mode = VC_INPUT_VOICE_ACTIVATION; 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) { std::string a = argv[i]; auto next = [&]() -> std::string { return (i + 1 < argc) ? argv[++i] : std::string(); }; if (a == "--host") host = next(); - else if (a == "--port") port = static_cast(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 == "--channel") channel_id = static_cast(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 == "--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 == "--list-devices") list_devices = 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 (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(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 { 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(), VOICECAT_PROTOCOL_VERSION); @@ -231,8 +508,15 @@ int main(int argc, char** argv) { return 1; } - 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)); + 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()); + 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()) { 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 } + // 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) { // Give the async UDP binding handshake (TCP UdpBinding -> ack -> plaintext // bootstrap packet) a moment to land before announcing a stream. @@ -315,5 +705,5 @@ int main(int argc, char** argv) { vc_disconnect(c); vc_client_destroy(c); std::printf("ok\n"); - return 0; + return m5_error ? 1 : 0; }