M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD). - Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts. - C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account). - vccli flags for all M5 operations plus --username/--password auth. - Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD. - Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
This commit is contained in:
60
PROGRESS.md
60
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<voicecat::codec::OpusApplication>(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<voicecat::v1::OpusApplication>(c.application));
|
||||
a.set_fec(c.fec != 0);
|
||||
a.set_expected_packet_loss(c.expected_packet_loss);
|
||||
a.set_dtx(c.dtx != 0);
|
||||
a.set_complexity(c.complexity);
|
||||
return a;
|
||||
}
|
||||
|
||||
voicecat::v1::Channel channel_from_vc(const vc_channel_info& c) {
|
||||
voicecat::v1::Channel ch;
|
||||
ch.set_id(c.id);
|
||||
ch.set_parent_id(c.parent_id);
|
||||
ch.set_name(c.name ? c.name : "");
|
||||
ch.set_topic(c.topic ? c.topic : "");
|
||||
ch.set_password_protected(c.password_protected != 0);
|
||||
ch.set_max_users(c.max_users);
|
||||
ch.set_type(voicecat::v1::CHANNEL_PERMANENT);
|
||||
*ch.mutable_audio() = audio_config_from_vc(c.audio);
|
||||
ch.set_order(static_cast<int32_t>(c.sort_order));
|
||||
return ch;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
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<int>(VC_STREAM_MIC) &&
|
||||
self_mic_muted_.load(std::memory_order_acquire)) return;
|
||||
(self_mic_muted_.load(std::memory_order_acquire) ||
|
||||
server_muted_.load(std::memory_order_acquire))) return;
|
||||
|
||||
// Send-side input gate (docs/voice.md §11) — MIC only. SCREEN_AUDIO/AUX_DEVICE always
|
||||
// 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
|
||||
|
||||
@@ -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<bool> self_mic_muted_{false};
|
||||
std::atomic<bool> self_deafened_{false};
|
||||
std::atomic<bool> server_muted_{false};
|
||||
std::atomic<bool> server_deafened_{false};
|
||||
|
||||
// M5: permissions from last AuthResult.
|
||||
vc_permissions own_permissions_{};
|
||||
|
||||
// Follow-up to M3: send-side input gate (docs/voice.md §11). MIC-only — SCREEN_AUDIO/
|
||||
// AUX_DEVICE are never gated (see PROGRESS.md for the rationale). Pure local state, no
|
||||
|
||||
@@ -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; });
|
||||
|
||||
@@ -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<Stream> streams;
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Database> db,
|
||||
std::shared_ptr<SessionRegistry> registry,
|
||||
std::shared_ptr<voicecat::WorkerPool> workers,
|
||||
@@ -73,7 +89,7 @@ void ConnSession::on_frame(std::vector<uint8_t> 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<uint8_t> 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<milliseconds>(
|
||||
@@ -404,6 +564,170 @@ void ConnSession::handle_stream_stop(const voicecat::v1::StreamStop& msg) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── M5 handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
void ConnSession::handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
bool ok = registry_->kick_user(msg.user_id(), msg.reason());
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
|
||||
}
|
||||
|
||||
void ConnSession::handle_ban_request(uint64_t req_id, const voicecat::v1::BanRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_ban)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ban by username for persistence (nickname == username for password users).
|
||||
// Also ban by runtime user_id for immediate effect.
|
||||
if (auto target = registry_->find_session_by_user_id(msg.user_id())) {
|
||||
if (!target->user_id()) {
|
||||
send_generic_result(req_id, false, 3, "user not found");
|
||||
return;
|
||||
}
|
||||
if (auto nick = registry_->user_nickname(target->user_id())) {
|
||||
std::string err;
|
||||
db_->ban_create("username", *nick, msg.reason(),
|
||||
static_cast<int64_t>(msg.expires_unix_ms()), err);
|
||||
}
|
||||
}
|
||||
|
||||
bool ok = registry_->ban_user(msg.user_id(), msg.reason(),
|
||||
static_cast<int64_t>(msg.expires_unix_ms()));
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
|
||||
}
|
||||
|
||||
void ConnSession::handle_set_permission(uint64_t req_id,
|
||||
const voicecat::v1::SetPermissionRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
auto target = registry_->find_session_by_user_id(msg.user_id());
|
||||
if (!target) {
|
||||
send_generic_result(req_id, false, 3, "user not online");
|
||||
return;
|
||||
}
|
||||
target->set_permissions(msg.permissions());
|
||||
send_generic_result(req_id, true, 0, "");
|
||||
}
|
||||
|
||||
void ConnSession::handle_server_mute_request(uint64_t req_id,
|
||||
const voicecat::v1::ServerMuteRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
bool ok = registry_->set_server_mute(msg.user_id(), msg.muted(), msg.deafened());
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
|
||||
}
|
||||
|
||||
void ConnSession::handle_move_user(uint64_t req_id, const voicecat::v1::MoveUserRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_move_users)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
bool ok = registry_->move_user(msg.user_id(), msg.channel_id());
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user or channel not found");
|
||||
}
|
||||
|
||||
void ConnSession::handle_create_channel(uint64_t req_id,
|
||||
const voicecat::v1::CreateChannelRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_create_temp_channel)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
uint32_t id = registry_->create_channel(msg.channel(), msg.password(), error);
|
||||
send_generic_result(req_id, id != 0, id != 0 ? 0 : 3,
|
||||
id != 0 ? "" : (error.empty() ? "create failed" : error));
|
||||
}
|
||||
|
||||
void ConnSession::handle_edit_channel(uint64_t req_id,
|
||||
const voicecat::v1::EditChannelRequest& msg) {
|
||||
if (!is_admin()) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
bool ok = registry_->update_channel(msg.channel(), msg.password(), error);
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3,
|
||||
ok ? "" : (error.empty() ? "update failed" : error));
|
||||
}
|
||||
|
||||
void ConnSession::handle_delete_channel(uint64_t req_id,
|
||||
const voicecat::v1::DeleteChannelRequest& msg) {
|
||||
if (!is_admin()) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
bool ok = registry_->delete_channel(msg.channel_id(), error);
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3,
|
||||
ok ? "" : (error.empty() ? "delete failed" : error));
|
||||
}
|
||||
|
||||
void ConnSession::handle_create_account(uint64_t req_id,
|
||||
const voicecat::v1::CreateAccountRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
auto self = shared_from_this();
|
||||
workers_->post([self, req_id, msg]() mutable {
|
||||
std::string error;
|
||||
auto acc = self->db_->create_account(msg.username(), msg.password(), false, error);
|
||||
self->send_generic_result(req_id, acc.has_value(), acc.has_value() ? 0 : 3,
|
||||
acc.has_value() ? "" : error);
|
||||
});
|
||||
}
|
||||
|
||||
void ConnSession::handle_reset_password(uint64_t req_id,
|
||||
const voicecat::v1::ResetPasswordRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
auto self = shared_from_this();
|
||||
workers_->post([self, req_id, msg]() mutable {
|
||||
std::string error;
|
||||
bool ok = self->db_->reset_password(msg.username(), msg.new_password(), error);
|
||||
self->send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : error);
|
||||
});
|
||||
}
|
||||
|
||||
void ConnSession::handle_delete_account(uint64_t req_id,
|
||||
const voicecat::v1::DeleteAccountRequest& msg) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
bool ok = db_->delete_account(msg.username(), error);
|
||||
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : error);
|
||||
}
|
||||
|
||||
void ConnSession::handle_list_accounts(uint64_t req_id,
|
||||
const voicecat::v1::ListAccountsRequest& /*msg*/) {
|
||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
||||
send_generic_result(req_id, false, 6, "permission denied");
|
||||
return;
|
||||
}
|
||||
auto env = make_env(req_id);
|
||||
auto* lr = env.mutable_list_accounts_result();
|
||||
for (const auto& acc : db_->list_accounts()) {
|
||||
auto* e = lr->add_accounts();
|
||||
e->set_username(acc.username);
|
||||
e->set_is_admin(acc.is_admin);
|
||||
e->set_created_at_unix_ms(static_cast<uint64_t>(acc.created_at) * 1000);
|
||||
e->set_last_login_unix_ms(static_cast<uint64_t>(acc.last_login) * 1000);
|
||||
}
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
void ConnSession::send_disconnect_and_close(uint32_t code, const std::string& reason) {
|
||||
auto env = make_env();
|
||||
auto* d = env.mutable_disconnect();
|
||||
|
||||
@@ -57,6 +57,10 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
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<voicecat::crypto::SodiumMediaCrypto> send,
|
||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv);
|
||||
@@ -87,6 +91,21 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
void handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBinding& msg);
|
||||
void handle_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<ConnSession> {
|
||||
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<Database> db_;
|
||||
std::shared_ptr<SessionRegistry> registry_;
|
||||
@@ -126,6 +151,9 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
// support multiple concurrent streams (MIC + SCREEN_AUDIO + AUX_DEVICE) per user.
|
||||
uint32_t next_stream_id_{1};
|
||||
std::vector<uint32_t> announced_stream_ids_;
|
||||
|
||||
// M5: permissions granted at auth time (server-side authority).
|
||||
voicecat::v1::Permissions permissions_;
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <random>
|
||||
#include <stdexcept>
|
||||
|
||||
#include <sodium.h>
|
||||
@@ -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<const char*>(sqlite3_column_text(stmt, 0))));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (version < 2) {
|
||||
if (!exec(kCreateSchemaV2, error)) return false;
|
||||
sqlite3_stmt* upd = nullptr;
|
||||
sqlite3_prepare_v2(db_,
|
||||
"INSERT OR REPLACE INTO server_meta (key, value) VALUES ('schema_version', ?)",
|
||||
-1, &upd, nullptr);
|
||||
sqlite3_bind_text(upd, 1, "2", -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(upd);
|
||||
sqlite3_finalize(upd);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -63,6 +127,8 @@ bool Database::is_empty() {
|
||||
return count == 0;
|
||||
}
|
||||
|
||||
// ── Accounts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
std::optional<Account> Database::create_account(const std::string& username,
|
||||
const std::string& password,
|
||||
bool is_admin, std::string& error) {
|
||||
@@ -108,6 +174,7 @@ std::optional<Account> Database::create_account(const std::string& username,
|
||||
|
||||
bool Database::reset_password(const std::string& username, const std::string& new_password,
|
||||
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<Account> 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<int>(a.codec()));
|
||||
sqlite3_bind_int(stmt, base_idx + 1, static_cast<int>(a.mode()));
|
||||
sqlite3_bind_int(stmt, base_idx + 2, static_cast<int>(a.sample_rate()));
|
||||
sqlite3_bind_int(stmt, base_idx + 3, static_cast<int>(a.bitrate_bps()));
|
||||
sqlite3_bind_int(stmt, base_idx + 4, static_cast<int>(a.frame_ms()));
|
||||
sqlite3_bind_int(stmt, base_idx + 5, static_cast<int>(a.application()));
|
||||
sqlite3_bind_int(stmt, base_idx + 6, a.fec() ? 1 : 0);
|
||||
sqlite3_bind_int(stmt, base_idx + 7, static_cast<int>(a.expected_packet_loss()));
|
||||
sqlite3_bind_int(stmt, base_idx + 8, a.dtx() ? 1 : 0);
|
||||
sqlite3_bind_int(stmt, base_idx + 9, static_cast<int>(a.complexity()));
|
||||
}
|
||||
|
||||
voicecat::v1::AudioConfig read_audio(sqlite3_stmt* stmt, int base_idx) {
|
||||
voicecat::v1::AudioConfig a;
|
||||
a.set_codec(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 0)));
|
||||
a.set_mode(static_cast<voicecat::v1::ChannelMode>(sqlite3_column_int(stmt, base_idx + 1)));
|
||||
a.set_sample_rate(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 2)));
|
||||
a.set_bitrate_bps(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 3)));
|
||||
a.set_frame_ms(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 4)));
|
||||
a.set_application(static_cast<voicecat::v1::OpusApplication>(sqlite3_column_int(stmt, base_idx + 5)));
|
||||
a.set_fec(sqlite3_column_int(stmt, base_idx + 6) != 0);
|
||||
a.set_expected_packet_loss(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 7)));
|
||||
a.set_dtx(sqlite3_column_int(stmt, base_idx + 8) != 0);
|
||||
a.set_complexity(static_cast<uint32_t>(sqlite3_column_int(stmt, base_idx + 9)));
|
||||
return a;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<ChannelRecord> Database::create_channel(const ChannelRecord& ch,
|
||||
const std::string& password,
|
||||
std::string& error) {
|
||||
if (ch.name.empty()) { error = "channel name must not be empty"; return std::nullopt; }
|
||||
|
||||
std::string pw_hash = password.empty() ? "" : hash_channel_password(password);
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = R"sql(
|
||||
INSERT INTO channels
|
||||
(parent_id, name, topic, password_hash, max_users, type,
|
||||
audio_codec, audio_mode, audio_sample_rate, audio_bitrate_bps, audio_frame_ms,
|
||||
audio_application, audio_fec, audio_expected_packet_loss, audio_dtx, audio_complexity,
|
||||
sort_order)
|
||||
VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?, ?)
|
||||
)sql";
|
||||
int rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
|
||||
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||||
|
||||
sqlite3_bind_int(stmt, 1, static_cast<int>(ch.parent_id));
|
||||
sqlite3_bind_text(stmt, 2, ch.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, ch.topic.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 4, pw_hash.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 5, static_cast<int>(ch.max_users));
|
||||
sqlite3_bind_int(stmt, 6, static_cast<int>(ch.type));
|
||||
bind_audio(stmt, 7, ch.audio); // parameters 7-16
|
||||
sqlite3_bind_int(stmt, 17, static_cast<int>(ch.sort_order));
|
||||
|
||||
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||||
|
||||
ChannelRecord out = ch;
|
||||
out.id = static_cast<uint32_t>(sqlite3_last_insert_rowid(db_));
|
||||
out.password_protected = !pw_hash.empty();
|
||||
return out;
|
||||
}
|
||||
|
||||
bool Database::update_channel(const ChannelRecord& ch, const std::string& password,
|
||||
std::string& error) {
|
||||
std::string pw_hash;
|
||||
bool update_password = false;
|
||||
if (!password.empty()) {
|
||||
pw_hash = hash_channel_password(password);
|
||||
update_password = true;
|
||||
}
|
||||
|
||||
std::string sql = R"sql(
|
||||
UPDATE channels SET
|
||||
parent_id=?, name=?, topic=?, max_users=?, type=?,
|
||||
audio_codec=?, audio_mode=?, audio_sample_rate=?, audio_bitrate_bps=?, audio_frame_ms=?,
|
||||
audio_application=?, audio_fec=?, audio_expected_packet_loss=?, audio_dtx=?, audio_complexity=?,
|
||||
sort_order=?
|
||||
)sql";
|
||||
if (update_password) sql += ", password_hash=?";
|
||||
sql += " WHERE id=?";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
int rc = sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
|
||||
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return false; }
|
||||
|
||||
int idx = 1;
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.parent_id));
|
||||
sqlite3_bind_text(stmt, idx++, ch.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, idx++, ch.topic.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.max_users));
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.type));
|
||||
bind_audio(stmt, idx, ch.audio); idx += 10;
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.sort_order));
|
||||
if (update_password) {
|
||||
sqlite3_bind_text(stmt, idx++, pw_hash.c_str(), -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
sqlite3_bind_int(stmt, idx++, static_cast<int>(ch.id));
|
||||
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||||
if (sqlite3_changes(db_) == 0) { error = "channel not found"; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::delete_channel(uint32_t id, std::string& error) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "DELETE FROM channels WHERE id=?", -1, &stmt, nullptr);
|
||||
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||||
if (sqlite3_changes(db_) == 0) { error = "channel not found"; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<ChannelRecord> Database::list_channels() {
|
||||
std::vector<ChannelRecord> result;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, R"sql(
|
||||
SELECT id, parent_id, name, topic, password_hash, max_users, type,
|
||||
audio_codec, audio_mode, audio_sample_rate, audio_bitrate_bps, audio_frame_ms,
|
||||
audio_application, audio_fec, audio_expected_packet_loss, audio_dtx, audio_complexity,
|
||||
sort_order
|
||||
FROM channels
|
||||
ORDER BY sort_order, id
|
||||
)sql", -1, &stmt, nullptr);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ChannelRecord ch;
|
||||
ch.id = static_cast<uint32_t>(sqlite3_column_int(stmt, 0));
|
||||
ch.parent_id = static_cast<uint32_t>(sqlite3_column_int(stmt, 1));
|
||||
ch.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
ch.topic = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0';
|
||||
ch.max_users = static_cast<uint32_t>(sqlite3_column_int(stmt, 5));
|
||||
ch.type = static_cast<voicecat::v1::ChannelType>(sqlite3_column_int(stmt, 6));
|
||||
ch.audio = read_audio(stmt, 7);
|
||||
ch.sort_order = sqlite3_column_int(stmt, 17);
|
||||
result.push_back(std::move(ch));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<ChannelRecord> Database::get_channel(uint32_t id) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, R"sql(
|
||||
SELECT id, parent_id, name, topic, password_hash, max_users, type,
|
||||
audio_codec, audio_mode, audio_sample_rate, audio_bitrate_bps, audio_frame_ms,
|
||||
audio_application, audio_fec, audio_expected_packet_loss, audio_dtx, audio_complexity,
|
||||
sort_order
|
||||
FROM channels WHERE id=?
|
||||
)sql", -1, &stmt, nullptr);
|
||||
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
|
||||
std::optional<ChannelRecord> result;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
ChannelRecord ch;
|
||||
ch.id = static_cast<uint32_t>(sqlite3_column_int(stmt, 0));
|
||||
ch.parent_id = static_cast<uint32_t>(sqlite3_column_int(stmt, 1));
|
||||
ch.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
ch.topic = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
ch.password_protected = sqlite3_column_text(stmt, 4)[0] != '\0';
|
||||
ch.max_users = static_cast<uint32_t>(sqlite3_column_int(stmt, 5));
|
||||
ch.type = static_cast<voicecat::v1::ChannelType>(sqlite3_column_int(stmt, 6));
|
||||
ch.audio = read_audio(stmt, 7);
|
||||
ch.sort_order = sqlite3_column_int(stmt, 17);
|
||||
result = std::move(ch);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string Database::hash_channel_password(const std::string& password) {
|
||||
uint8_t salt[16];
|
||||
randombytes_buf(salt, sizeof(salt));
|
||||
uint8_t hash[32];
|
||||
crypto_generichash(hash, sizeof(hash),
|
||||
reinterpret_cast<const uint8_t*>(password.data()), password.size(),
|
||||
salt, sizeof(salt));
|
||||
char salt_hex[33];
|
||||
char hash_hex[65];
|
||||
sodium_bin2hex(salt_hex, sizeof(salt_hex), salt, sizeof(salt));
|
||||
sodium_bin2hex(hash_hex, sizeof(hash_hex), hash, sizeof(hash));
|
||||
return std::string(salt_hex) + ":" + hash_hex;
|
||||
}
|
||||
|
||||
bool Database::verify_channel_password(const std::string& password, const std::string& stored) {
|
||||
auto pos = stored.find(':');
|
||||
if (pos == std::string::npos || pos != 32) return false;
|
||||
std::string salt_hex = stored.substr(0, pos);
|
||||
std::string hash_hex = stored.substr(pos + 1);
|
||||
uint8_t salt[16];
|
||||
uint8_t expected[32];
|
||||
if (sodium_hex2bin(salt, sizeof(salt), salt_hex.c_str(), salt_hex.size(),
|
||||
nullptr, nullptr, nullptr) != 0)
|
||||
return false;
|
||||
if (sodium_hex2bin(expected, sizeof(expected), hash_hex.c_str(), hash_hex.size(),
|
||||
nullptr, nullptr, nullptr) != 0)
|
||||
return false;
|
||||
uint8_t hash[32];
|
||||
crypto_generichash(hash, sizeof(hash),
|
||||
reinterpret_cast<const uint8_t*>(password.data()), password.size(),
|
||||
salt, sizeof(salt));
|
||||
return sodium_memcmp(hash, expected, sizeof(hash)) == 0;
|
||||
}
|
||||
|
||||
bool Database::check_channel_password(uint32_t id, const std::string& password) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "SELECT password_hash FROM channels WHERE id=?", -1, &stmt, nullptr);
|
||||
sqlite3_bind_int(stmt, 1, static_cast<int>(id));
|
||||
bool ok = false;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* stored = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (!stored || stored[0] == '\0') {
|
||||
ok = true; // no password
|
||||
} else {
|
||||
ok = verify_channel_password(password, stored);
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ── Bans ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
std::optional<BanRecord> Database::ban_create(const std::string& subject_type,
|
||||
const std::string& subject,
|
||||
const std::string& reason,
|
||||
int64_t expires_at,
|
||||
std::string& error) {
|
||||
if (subject_type != "user_id" && subject_type != "username" && subject_type != "ip") {
|
||||
error = "invalid subject_type";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (subject.empty()) { error = "subject must not be empty"; return std::nullopt; }
|
||||
|
||||
int64_t now = now_unix();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
int rc = sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO bans (subject_type, subject, reason, expires_at, created_at) VALUES (?,?,?,?,?)",
|
||||
-1, &stmt, nullptr);
|
||||
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||||
sqlite3_bind_text(stmt, 1, subject_type.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, subject.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, reason.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 4, expires_at);
|
||||
sqlite3_bind_int64(stmt, 5, now);
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||||
|
||||
BanRecord b;
|
||||
b.id = sqlite3_last_insert_rowid(db_);
|
||||
b.subject_type = subject_type;
|
||||
b.subject = subject;
|
||||
b.reason = reason;
|
||||
b.expires_at = expires_at;
|
||||
b.created_at = now;
|
||||
return b;
|
||||
}
|
||||
|
||||
bool Database::ban_remove(int64_t id, std::string& error) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "DELETE FROM bans WHERE id=?", -1, &stmt, nullptr);
|
||||
sqlite3_bind_int64(stmt, 1, id);
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||||
if (sqlite3_changes(db_) == 0) { error = "ban not found"; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::ban_check(const std::string& subject_type, const std::string& subject) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_,
|
||||
"SELECT expires_at FROM bans WHERE subject_type=? AND subject=? AND (expires_at=0 OR expires_at>?)",
|
||||
-1, &stmt, nullptr);
|
||||
sqlite3_bind_text(stmt, 1, subject_type.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, subject.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 3, now_unix());
|
||||
bool banned = false;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) banned = true;
|
||||
sqlite3_finalize(stmt);
|
||||
return banned;
|
||||
}
|
||||
|
||||
std::vector<BanRecord> Database::ban_list(bool include_expired) {
|
||||
std::vector<BanRecord> result;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
std::string sql = "SELECT id, subject_type, subject, reason, expires_at, created_at FROM bans";
|
||||
if (!include_expired) sql += " WHERE expires_at=0 OR expires_at>?";
|
||||
sql += " ORDER BY created_at DESC";
|
||||
sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
|
||||
if (!include_expired) sqlite3_bind_int64(stmt, 1, now_unix());
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
BanRecord b;
|
||||
b.id = sqlite3_column_int64(stmt, 0);
|
||||
b.subject_type = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
b.subject = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
b.reason = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
b.expires_at = sqlite3_column_int64(stmt, 4);
|
||||
b.created_at = sqlite3_column_int64(stmt, 5);
|
||||
result.push_back(std::move(b));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string Database::generate_password(size_t length) {
|
||||
static const char kAlphabet[] =
|
||||
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*";
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<Account> create_account(const std::string& username,
|
||||
const std::string& password,
|
||||
@@ -63,13 +90,62 @@ class Database {
|
||||
std::optional<Account> 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<ChannelRecord> create_channel(const ChannelRecord& ch,
|
||||
const std::string& password,
|
||||
std::string& error);
|
||||
|
||||
// Update an existing channel. Returns false if not found or on error.
|
||||
bool update_channel(const ChannelRecord& ch, const std::string& password,
|
||||
std::string& error);
|
||||
|
||||
// Delete a channel. Returns false if not found or on error.
|
||||
bool delete_channel(uint32_t id, std::string& error);
|
||||
|
||||
// Load all channels. Thread-safe.
|
||||
std::vector<ChannelRecord> list_channels();
|
||||
|
||||
// Fetch a single channel. Thread-safe.
|
||||
std::optional<ChannelRecord> get_channel(uint32_t id);
|
||||
|
||||
// Check a plaintext channel password. Returns true if the channel has no
|
||||
// password or if the password matches. Fast (BLAKE2b), safe for net thread.
|
||||
bool check_channel_password(uint32_t id, const std::string& password);
|
||||
|
||||
// ── Bans ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Ban a subject (user_id or ip). Returns the persisted record, or nullopt.
|
||||
std::optional<BanRecord> ban_create(const std::string& subject_type,
|
||||
const std::string& subject,
|
||||
const std::string& reason,
|
||||
int64_t expires_at,
|
||||
std::string& error);
|
||||
|
||||
// Remove a ban by id. Returns false if not found or on error.
|
||||
bool ban_remove(int64_t id, std::string& error);
|
||||
|
||||
// Check whether a subject is currently banned. Thread-safe.
|
||||
bool ban_check(const std::string& subject_type, const std::string& subject);
|
||||
|
||||
// List active bans (optionally all). Thread-safe.
|
||||
std::vector<BanRecord> ban_list(bool include_expired = false);
|
||||
|
||||
// Generate a random printable password of the given length.
|
||||
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};
|
||||
};
|
||||
|
||||
@@ -54,8 +54,8 @@ int Server::run() {
|
||||
}
|
||||
|
||||
// ── Session registry ─────────────────────────────────────────────────────
|
||||
auto registry = std::make_shared<SessionRegistry>();
|
||||
registry->init_default_channels();
|
||||
auto registry = std::make_shared<SessionRegistry>(db);
|
||||
registry->load_channels();
|
||||
|
||||
// ── Worker pool ──────────────────────────────────────────────────────────
|
||||
auto workers = std::make_shared<WorkerPool>(3);
|
||||
|
||||
@@ -10,54 +10,82 @@
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
void SessionRegistry::init_default_channels() {
|
||||
SessionRegistry::SessionRegistry(std::shared_ptr<Database> db) : db_(std::move(db)) {}
|
||||
|
||||
void SessionRegistry::load_channels() {
|
||||
std::unique_lock lk(mu_);
|
||||
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<ConnSession> session) {
|
||||
@@ -70,6 +98,7 @@ uint64_t SessionRegistry::register_session(std::weak_ptr<ConnSession> 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<voicecat::v1::User> SessionRegistry::user_snapshot() const {
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<voicecat::v1::User> SessionRegistry::user_snapshot_user(uint32_t user_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it == users_.end()) return std::nullopt;
|
||||
return it->second.proto;
|
||||
}
|
||||
|
||||
std::optional<std::string> SessionRegistry::user_nickname(uint32_t user_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it == users_.end()) return std::nullopt;
|
||||
return it->second.proto.nickname();
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<ConnSession>> SessionRegistry::resolve_text_targets(
|
||||
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<std::shared_ptr<ConnSession>> 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<voicecat::v1::Permissions> SessionRegistry::get_session_permissions(
|
||||
uint64_t session_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = session_permissions_.find(session_id);
|
||||
if (it == session_permissions_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
// ── Moderation ────────────────────────────────────────────────────────────────
|
||||
|
||||
std::shared_ptr<ConnSession> SessionRegistry::find_session_by_user_id(uint32_t user_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it == users_.end()) return nullptr;
|
||||
auto sit = sessions_.find(it->second.session_id);
|
||||
if (sit == sessions_.end()) return nullptr;
|
||||
return sit->second.lock();
|
||||
}
|
||||
|
||||
namespace {
|
||||
voicecat::v1::Envelope make_left_event(uint32_t user_id, const std::string& reason) {
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ue = env.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::LEFT);
|
||||
ue->mutable_user()->set_id(user_id);
|
||||
ue->set_left_id(user_id);
|
||||
ue->set_reason(reason); // M5 additive field
|
||||
return env;
|
||||
}
|
||||
}
|
||||
|
||||
bool SessionRegistry::kick_user(uint32_t user_id, const std::string& reason) {
|
||||
auto target = find_session_by_user_id(user_id);
|
||||
if (!target) return false;
|
||||
{
|
||||
std::shared_lock lk(mu_);
|
||||
broadcast(make_left_event(user_id, reason), /*exclude*/ 0);
|
||||
}
|
||||
// Close outside the registry lock: close() may call back into unregister_session().
|
||||
target->send_disconnect_and_close(2, reason); // code 2 = kicked
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SessionRegistry::ban_user(uint32_t user_id, const std::string& reason, int64_t expires_at) {
|
||||
{
|
||||
std::unique_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it != users_.end()) {
|
||||
std::string err;
|
||||
db_->ban_create("user_id", std::to_string(user_id), reason, expires_at, err);
|
||||
}
|
||||
}
|
||||
return kick_user(user_id, reason);
|
||||
}
|
||||
|
||||
bool SessionRegistry::set_server_mute(uint32_t user_id, bool muted, bool deafened) {
|
||||
std::unique_lock lk(mu_);
|
||||
auto it = users_.find(user_id);
|
||||
if (it == users_.end()) return false;
|
||||
it->second.proto.set_server_muted(muted);
|
||||
it->second.proto.set_server_deafened(deafened);
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ue = env.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = it->second.proto;
|
||||
broadcast_unlocked(env, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SessionRegistry::move_user(uint32_t user_id, uint32_t channel_id) {
|
||||
std::unique_lock lk(mu_);
|
||||
auto ch_it = channels_.find(channel_id);
|
||||
if (ch_it == channels_.end()) return false;
|
||||
auto user_it = users_.find(user_id);
|
||||
if (user_it == users_.end()) return false;
|
||||
|
||||
user_it->second.proto.set_channel_id(channel_id);
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ue = env.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = user_it->second.proto;
|
||||
broadcast_unlocked(env, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Channel CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
uint32_t SessionRegistry::create_channel(const voicecat::v1::Channel& ch,
|
||||
const std::string& password,
|
||||
std::string& error) {
|
||||
ChannelRecord rec;
|
||||
rec.parent_id = ch.parent_id();
|
||||
rec.name = ch.name();
|
||||
rec.topic = ch.topic();
|
||||
rec.max_users = ch.max_users();
|
||||
rec.type = ch.type();
|
||||
rec.audio = ch.audio();
|
||||
rec.sort_order = ch.order();
|
||||
|
||||
auto result = db_->create_channel(rec, password, error);
|
||||
if (!result) return 0;
|
||||
|
||||
std::unique_lock lk(mu_);
|
||||
uint32_t id = result->id;
|
||||
ChannelEntry entry;
|
||||
entry.proto = ch;
|
||||
entry.proto.set_id(id);
|
||||
entry.proto.set_password_protected(result->password_protected);
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ce = env.mutable_channel_event();
|
||||
ce->set_kind(voicecat::v1::ChannelEvent::CREATED);
|
||||
*ce->mutable_channel() = entry.proto;
|
||||
|
||||
channels_[id] = std::move(entry);
|
||||
next_channel_id_ = std::max(next_channel_id_, id + 1);
|
||||
|
||||
broadcast_unlocked(env, 0);
|
||||
return id;
|
||||
}
|
||||
|
||||
bool SessionRegistry::update_channel(const voicecat::v1::Channel& ch,
|
||||
const std::string& password,
|
||||
std::string& error) {
|
||||
ChannelRecord rec;
|
||||
rec.id = ch.id();
|
||||
rec.parent_id = ch.parent_id();
|
||||
rec.name = ch.name();
|
||||
rec.topic = ch.topic();
|
||||
rec.max_users = ch.max_users();
|
||||
rec.type = ch.type();
|
||||
rec.audio = ch.audio();
|
||||
rec.sort_order = ch.order();
|
||||
|
||||
if (!db_->update_channel(rec, password, error)) return false;
|
||||
|
||||
std::unique_lock lk(mu_);
|
||||
auto it = channels_.find(ch.id());
|
||||
if (it == channels_.end()) {
|
||||
error = "channel not found";
|
||||
return false;
|
||||
}
|
||||
it->second.proto = ch;
|
||||
it->second.proto.set_password_protected(
|
||||
!password.empty() || db_->get_channel(ch.id())->password_protected);
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ce = env.mutable_channel_event();
|
||||
ce->set_kind(voicecat::v1::ChannelEvent::UPDATED);
|
||||
*ce->mutable_channel() = it->second.proto;
|
||||
broadcast_unlocked(env, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SessionRegistry::delete_channel(uint32_t channel_id, std::string& error) {
|
||||
if (channel_id == 1) { error = "cannot delete root channel"; return false; }
|
||||
if (!db_->delete_channel(channel_id, error)) return false;
|
||||
|
||||
std::unique_lock lk(mu_);
|
||||
channels_.erase(channel_id);
|
||||
|
||||
// Move any users left in the deleted channel to Lobby.
|
||||
for (auto& [uid, entry] : users_) {
|
||||
if (entry.proto.channel_id() != channel_id) continue;
|
||||
entry.proto.set_channel_id(1);
|
||||
voicecat::v1::Envelope uev;
|
||||
auto* ue = uev.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
||||
*ue->mutable_user() = entry.proto;
|
||||
broadcast_unlocked(uev, 0);
|
||||
}
|
||||
|
||||
voicecat::v1::Envelope env;
|
||||
auto* ce = env.mutable_channel_event();
|
||||
ce->set_kind(voicecat::v1::ChannelEvent::DELETED);
|
||||
ce->set_deleted_id(channel_id);
|
||||
broadcast_unlocked(env, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<voicecat::v1::Channel> SessionRegistry::get_channel(uint32_t channel_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
auto it = channels_.find(channel_id);
|
||||
if (it == channels_.end()) return std::nullopt;
|
||||
return it->second.proto;
|
||||
}
|
||||
|
||||
bool SessionRegistry::check_channel_password(uint32_t channel_id,
|
||||
const std::string& password) const {
|
||||
return db_->check_channel_password(channel_id, password);
|
||||
}
|
||||
|
||||
// ── M2: UDP / media ───────────────────────────────────────────────────────────
|
||||
|
||||
void SessionRegistry::register_udp_token(const std::array<uint8_t, 16>& token,
|
||||
uint64_t session_id) {
|
||||
@@ -170,7 +420,7 @@ std::shared_ptr<ConnSession> 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<voicecat::v1::User> SessionRegistry::set_user_stream(
|
||||
}
|
||||
|
||||
std::optional<voicecat::v1::User> 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;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#define ASIO_STANDALONE 1
|
||||
#include <asio.hpp>
|
||||
|
||||
#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<Database> 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<ConnSession> session);
|
||||
@@ -71,6 +72,8 @@ class SessionRegistry {
|
||||
// Snapshot for ServerStateSnapshot message.
|
||||
std::vector<voicecat::v1::Channel> channel_snapshot() const;
|
||||
std::vector<voicecat::v1::User> user_snapshot() const;
|
||||
std::optional<voicecat::v1::User> user_snapshot_user(uint32_t user_id) const;
|
||||
std::optional<std::string> user_nickname(uint32_t user_id) const;
|
||||
|
||||
// Resolve target sessions for a text message relay.
|
||||
std::vector<std::shared_ptr<ConnSession>> resolve_text_targets(
|
||||
@@ -79,7 +82,56 @@ class SessionRegistry {
|
||||
// Broadcast an envelope to all sessions except the excluded one.
|
||||
void broadcast(const voicecat::v1::Envelope& env, uint64_t exclude_session_id = 0) const;
|
||||
|
||||
// ── M2: UDP / media ──────────────────────────────────────────────────────
|
||||
private:
|
||||
void broadcast_unlocked(const voicecat::v1::Envelope& env,
|
||||
uint64_t exclude_session_id = 0) const;
|
||||
|
||||
public:
|
||||
// ── Permissions ────────────────────────────────────────────────────────────
|
||||
|
||||
void set_session_permissions(uint64_t session_id,
|
||||
const voicecat::v1::Permissions& perms);
|
||||
std::optional<voicecat::v1::Permissions> get_session_permissions(
|
||||
uint64_t session_id) const;
|
||||
|
||||
// ── Moderation ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Find a live session by its user_id. Returns nullptr if offline.
|
||||
std::shared_ptr<ConnSession> find_session_by_user_id(uint32_t user_id) const;
|
||||
|
||||
// Forcibly disconnect a user with a reason. Broadcasts UserEvent::LEFT.
|
||||
// Returns true if the user was online.
|
||||
bool kick_user(uint32_t user_id, const std::string& reason);
|
||||
|
||||
// Kick a user and insert a persistent ban. Returns true if the user was online.
|
||||
bool ban_user(uint32_t user_id, const std::string& reason, int64_t expires_at);
|
||||
|
||||
// Set server-mute/deafen flags on a user and broadcast the update.
|
||||
bool set_server_mute(uint32_t user_id, bool muted, bool deafened);
|
||||
|
||||
// Move a user to a channel (permission-checked by caller).
|
||||
bool move_user(uint32_t user_id, uint32_t channel_id);
|
||||
|
||||
// ── Channel CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
// Create a channel. Returns the new channel id, or 0 on error.
|
||||
uint32_t create_channel(const voicecat::v1::Channel& ch, const std::string& password,
|
||||
std::string& error);
|
||||
|
||||
// Update a channel. Returns false on error.
|
||||
bool update_channel(const voicecat::v1::Channel& ch, const std::string& password,
|
||||
std::string& error);
|
||||
|
||||
// Delete a channel. Remaining users are moved to Lobby (id=1). Returns false on error.
|
||||
bool delete_channel(uint32_t channel_id, std::string& error);
|
||||
|
||||
// Return a channel proto by id, or nullopt.
|
||||
std::optional<voicecat::v1::Channel> get_channel(uint32_t channel_id) const;
|
||||
|
||||
// Check a channel password.
|
||||
bool check_channel_password(uint32_t channel_id, const std::string& password) const;
|
||||
|
||||
// ── M2: UDP / media ────────────────────────────────────────────────────────
|
||||
|
||||
// Register a session's UDP token (called at auth success).
|
||||
void register_udp_token(const std::array<uint8_t, 16>& 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<voicecat::v1::User> 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<voicecat::v1::AudioConfig> channel_audio_config(uint32_t channel_id) const;
|
||||
|
||||
private:
|
||||
void seed_default_channels();
|
||||
|
||||
mutable std::shared_mutex mu_;
|
||||
|
||||
std::shared_ptr<Database> 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<uint64_t, std::weak_ptr<ConnSession>> sessions_;
|
||||
std::unordered_map<uint32_t, UserEntry> users_;
|
||||
std::unordered_map<uint32_t, ChannelEntry> channels_;
|
||||
std::unordered_map<uint64_t, voicecat::v1::Permissions> session_permissions_;
|
||||
|
||||
// M2: token → session_id (populated at auth, cleared on disconnect)
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
276
tests/test_m5_admin_accounts.cpp
Normal file
276
tests/test_m5_admin_accounts.cpp
Normal file
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* test_m5_admin_accounts — Phase 3 of M5: in-app account management over the wire.
|
||||
*
|
||||
* Verifies:
|
||||
* - admin can create an account via vc_create_account.
|
||||
* - the new account can authenticate.
|
||||
* - admin can reset the password.
|
||||
* - the new account can authenticate with the new password.
|
||||
* - admin can list accounts and sees the new entry.
|
||||
* - admin can delete the account.
|
||||
* - the deleted account can no longer authenticate.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
bool auth_done{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool disconnected{false};
|
||||
|
||||
struct ResultAck {
|
||||
bool ok{false};
|
||||
uint32_t code{0};
|
||||
std::string message;
|
||||
};
|
||||
std::vector<ResultAck> generic_results;
|
||||
|
||||
bool account_list_received{false};
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->auth_done = true;
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_GENERIC_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->u32a;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_ACCOUNT_LIST:
|
||||
s->account_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template<typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool wait_generic(EventStore& s, int timeout_ms) {
|
||||
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
|
||||
}
|
||||
|
||||
static bool last_generic_ok(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
return !s.generic_results.empty() && s.generic_results.back().ok;
|
||||
}
|
||||
|
||||
static void reset_generic(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.generic_results.clear();
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m5_acct_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
|
||||
if (!db.create_account("admin", "admin-pass", true, err)) {
|
||||
std::printf("FAIL: create admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-M5-Acct";
|
||||
cfg.allow_guests = false;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
EventStore evAdmin;
|
||||
evAdmin.label = "admin";
|
||||
vc_callbacks cbA{on_event, nullptr, &evAdmin};
|
||||
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
|
||||
vc_client* admin = vc_client_create(&cfgA, cbA);
|
||||
evAdmin.client = admin;
|
||||
CHECK(admin != nullptr);
|
||||
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
|
||||
// Create charlie.
|
||||
reset_generic(evAdmin);
|
||||
CHECK(vc_create_account(admin, "charlie", "charlie-pass") == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 5000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Charlie logs in.
|
||||
{
|
||||
EventStore evCharlie;
|
||||
evCharlie.label = "charlie";
|
||||
vc_callbacks cb{on_event, nullptr, &evCharlie};
|
||||
vc_config cfgC{"test-charlie", "0.1", VC_LOG_OFF};
|
||||
vc_client* charlie = vc_client_create(&cfgC, cb);
|
||||
evCharlie.client = charlie;
|
||||
CHECK(charlie != nullptr);
|
||||
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(charlie, "charlie", "charlie-pass") == VC_OK);
|
||||
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(evCharlie.auth_ok);
|
||||
vc_disconnect(charlie);
|
||||
vc_client_destroy(charlie);
|
||||
}
|
||||
|
||||
// Reset charlie's password.
|
||||
reset_generic(evAdmin);
|
||||
CHECK(vc_reset_password(admin, "charlie", "new-pass") == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 5000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Charlie logs in with new password.
|
||||
{
|
||||
EventStore evCharlie;
|
||||
evCharlie.label = "charlie2";
|
||||
vc_callbacks cb{on_event, nullptr, &evCharlie};
|
||||
vc_config cfgC{"test-charlie2", "0.1", VC_LOG_OFF};
|
||||
vc_client* charlie = vc_client_create(&cfgC, cb);
|
||||
evCharlie.client = charlie;
|
||||
CHECK(charlie != nullptr);
|
||||
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(charlie, "charlie", "new-pass") == VC_OK);
|
||||
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(evCharlie.auth_ok);
|
||||
vc_disconnect(charlie);
|
||||
vc_client_destroy(charlie);
|
||||
}
|
||||
|
||||
// List accounts.
|
||||
reset_generic(evAdmin);
|
||||
evAdmin.account_list_received = false;
|
||||
CHECK(vc_list_accounts(admin) == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.account_list_received; }, 3000));
|
||||
|
||||
// Delete charlie.
|
||||
reset_generic(evAdmin);
|
||||
CHECK(vc_delete_account(admin, "charlie") == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 3000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Charlie cannot log in anymore.
|
||||
{
|
||||
EventStore evCharlie;
|
||||
evCharlie.label = "charlie3";
|
||||
vc_callbacks cb{on_event, nullptr, &evCharlie};
|
||||
vc_config cfgC{"test-charlie3", "0.1", VC_LOG_OFF};
|
||||
vc_client* charlie = vc_client_create(&cfgC, cb);
|
||||
evCharlie.client = charlie;
|
||||
CHECK(charlie != nullptr);
|
||||
CHECK(vc_connect(charlie, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(charlie, "charlie", "new-pass") == VC_OK);
|
||||
CHECK(wait_for(evCharlie, [](EventStore& s){ return s.auth_done || s.disconnected; }, 20000));
|
||||
CHECK(!evCharlie.auth_ok);
|
||||
vc_disconnect(charlie);
|
||||
vc_client_destroy(charlie);
|
||||
}
|
||||
|
||||
vc_disconnect(admin);
|
||||
vc_client_destroy(admin);
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m5_admin_accounts: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m5_admin_accounts: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
#else // !VOICECAT_HAS_NET
|
||||
|
||||
int main() {
|
||||
std::printf("m5_admin_accounts: SKIP (VOICECAT_HAS_NET not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
319
tests/test_m5_channel_crud.cpp
Normal file
319
tests/test_m5_channel_crud.cpp
Normal file
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* test_m5_channel_crud — Phase 4 of M5: channel CRUD & password enforcement.
|
||||
*
|
||||
* Verifies:
|
||||
* - admin can create a password-protected channel.
|
||||
* - normal user cannot join without the password.
|
||||
* - normal user can join with the password.
|
||||
* - admin can edit the channel name.
|
||||
* - both clients receive VC_EVENT_CHANNEL_LIST after the update.
|
||||
* - admin can delete the channel; remaining users are moved to Lobby.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
int channel_list_count{0};
|
||||
bool disconnected{false};
|
||||
|
||||
struct ResultAck {
|
||||
bool ok{false};
|
||||
uint32_t code{0};
|
||||
std::string message;
|
||||
};
|
||||
std::vector<ResultAck> generic_results;
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
++s->channel_list_count;
|
||||
break;
|
||||
case VC_EVENT_JOIN_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->channel_id;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_GENERIC_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->u32a;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template<typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool wait_generic(EventStore& s, int timeout_ms) {
|
||||
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
|
||||
}
|
||||
|
||||
static bool last_generic_ok(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
return !s.generic_results.empty() && s.generic_results.back().ok;
|
||||
}
|
||||
|
||||
static void reset_generic(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.generic_results.clear();
|
||||
}
|
||||
|
||||
static int channel_count(vc_client* c) {
|
||||
vc_channel_list cl{};
|
||||
if (vc_list_channels(c, &cl) != VC_OK) return -1;
|
||||
int n = static_cast<int>(cl.count);
|
||||
vc_free_channel_list(&cl);
|
||||
return n;
|
||||
}
|
||||
|
||||
static uint32_t find_channel_by_name(vc_client* c, const char* name) {
|
||||
vc_channel_list cl{};
|
||||
if (vc_list_channels(c, &cl) != VC_OK) return 0;
|
||||
uint32_t id = 0;
|
||||
for (size_t i = 0; i < cl.count; ++i) {
|
||||
if (std::strcmp(cl.items[i].name, name) == 0) {
|
||||
id = cl.items[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
vc_free_channel_list(&cl);
|
||||
return id;
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m5_ch_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
|
||||
if (!db.create_account("admin", "admin-pass", true, err)) {
|
||||
std::printf("FAIL: create admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
if (!db.create_account("bob", "bob-pass", false, err)) {
|
||||
std::printf("FAIL: create bob: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-M5-Ch";
|
||||
cfg.allow_guests = false;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
EventStore evAdmin;
|
||||
evAdmin.label = "admin";
|
||||
vc_callbacks cbA{on_event, nullptr, &evAdmin};
|
||||
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
|
||||
vc_client* admin = vc_client_create(&cfgA, cbA);
|
||||
evAdmin.client = admin;
|
||||
CHECK(admin != nullptr);
|
||||
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evAdmin, [](EventStore& s){ return s.channel_list_count > 0; }, 3000));
|
||||
|
||||
EventStore evBob;
|
||||
evBob.label = "bob";
|
||||
vc_callbacks cbB{on_event, nullptr, &evBob};
|
||||
vc_config cfgB{"test-bob", "0.1", VC_LOG_OFF};
|
||||
vc_client* bob = vc_client_create(&cfgB, cbB);
|
||||
evBob.client = bob;
|
||||
CHECK(bob != nullptr);
|
||||
CHECK(vc_connect(bob, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(bob, "bob", "bob-pass") == VC_OK);
|
||||
CHECK(wait_for(evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evBob, [](EventStore& s){ return s.channel_list_count > 0; }, 3000));
|
||||
|
||||
// Admin creates a password-protected channel.
|
||||
reset_generic(evAdmin);
|
||||
int base_count = channel_count(admin);
|
||||
CHECK(base_count >= 2); // Lobby + Music Room
|
||||
|
||||
vc_channel_info ch{};
|
||||
ch.name = "Private Room";
|
||||
ch.topic = "secret";
|
||||
ch.password_protected = 1;
|
||||
ch.password = "swordfish";
|
||||
ch.max_users = 10;
|
||||
ch.sort_order = 5;
|
||||
CHECK(vc_create_channel(admin, &ch) == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 3000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Wait for the new channel to appear in bob's list.
|
||||
CHECK(wait_for(evBob, [](EventStore& s){ return s.channel_list_count > 1; }, 3000));
|
||||
int new_count = channel_count(bob);
|
||||
CHECK(new_count == base_count + 1);
|
||||
|
||||
uint32_t private_id = find_channel_by_name(admin, "Private Room");
|
||||
CHECK(private_id != 0);
|
||||
|
||||
// Bob tries to join without password — should fail.
|
||||
reset_generic(evBob);
|
||||
CHECK(vc_join_channel(bob, private_id, nullptr) == VC_OK);
|
||||
CHECK(wait_generic(evBob, 3000));
|
||||
CHECK(!last_generic_ok(evBob));
|
||||
|
||||
// Bob joins with password — should succeed.
|
||||
reset_generic(evBob);
|
||||
CHECK(vc_join_channel(bob, private_id, "swordfish") == VC_OK);
|
||||
CHECK(wait_generic(evBob, 3000));
|
||||
CHECK(last_generic_ok(evBob));
|
||||
|
||||
// Admin edits the channel name.
|
||||
reset_generic(evAdmin);
|
||||
int admin_list_count = evAdmin.channel_list_count;
|
||||
vc_channel_info edit{};
|
||||
edit.id = private_id;
|
||||
edit.name = "Renamed Room";
|
||||
edit.topic = "still secret";
|
||||
edit.password_protected = 1;
|
||||
edit.password = "swordfish"; // keep same password
|
||||
edit.sort_order = 5;
|
||||
CHECK(vc_edit_channel(admin, &edit) == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 3000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Both clients should receive a channel-list update.
|
||||
CHECK(wait_for(evAdmin, [admin_list_count](EventStore& s){ return s.channel_list_count > admin_list_count; }, 3000));
|
||||
CHECK(wait_for(evBob, [private_id](EventStore& s){ return find_channel_by_name(s.client, "Renamed Room") == private_id; }, 3000));
|
||||
|
||||
// Admin deletes the channel.
|
||||
reset_generic(evAdmin);
|
||||
CHECK(vc_delete_channel(admin, private_id) == VC_OK);
|
||||
CHECK(wait_generic(evAdmin, 3000));
|
||||
CHECK(last_generic_ok(evAdmin));
|
||||
|
||||
// Bob should see the channel disappear and be back in Lobby (id=1).
|
||||
CHECK(wait_for(evBob, [private_id](EventStore& s){ return find_channel_by_name(s.client, "Renamed Room") == 0; }, 3000));
|
||||
{
|
||||
vc_user_list ul{};
|
||||
CHECK(vc_list_users(bob, &ul) == VC_OK);
|
||||
bool found_self = false;
|
||||
for (size_t i = 0; i < ul.count; ++i) {
|
||||
if (ul.items[i].id == evBob.self_user_id) {
|
||||
found_self = true;
|
||||
CHECK(ul.items[i].channel_id == 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
CHECK(found_self);
|
||||
vc_free_user_list(&ul);
|
||||
}
|
||||
|
||||
vc_disconnect(admin);
|
||||
vc_disconnect(bob);
|
||||
vc_client_destroy(admin);
|
||||
vc_client_destroy(bob);
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m5_channel_crud: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m5_channel_crud: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
#else // !VOICECAT_HAS_NET
|
||||
|
||||
int main() {
|
||||
std::printf("m5_channel_crud: SKIP (VOICECAT_HAS_NET not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
331
tests/test_m5_kick_ban_move_mute.cpp
Normal file
331
tests/test_m5_kick_ban_move_mute.cpp
Normal file
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* test_m5_kick_ban_move_mute — Phase 2 of M5: moderation.
|
||||
*
|
||||
* Verifies:
|
||||
* - admin can kick a user (target receives disconnect).
|
||||
* - admin can ban a password user; re-auth fails.
|
||||
* - admin can move a user to another channel.
|
||||
* - admin can server-mute/deafen a user; the target reflects it locally.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
bool auth_done{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool disconnected{false};
|
||||
vc_result disconnect_reason{VC_OK};
|
||||
std::string disconnect_text;
|
||||
|
||||
struct ResultAck {
|
||||
bool ok{false};
|
||||
uint32_t code{0};
|
||||
std::string message;
|
||||
};
|
||||
std::vector<ResultAck> generic_results;
|
||||
|
||||
uint32_t last_updated_user{0};
|
||||
uint32_t last_updated_channel{0};
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->auth_done = true;
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_GENERIC_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->u32a;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_USER_UPDATED:
|
||||
s->last_updated_user = ev->user_id;
|
||||
s->last_updated_channel = ev->channel_id;
|
||||
break;
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
s->disconnect_reason = static_cast<vc_result>(ev->result);
|
||||
s->disconnect_text = ev->text ? ev->text : "";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template<typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool wait_generic(EventStore& s, int timeout_ms) {
|
||||
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
|
||||
}
|
||||
|
||||
static bool last_generic_ok(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
return !s.generic_results.empty() && s.generic_results.back().ok;
|
||||
}
|
||||
|
||||
static void reset_generic(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.generic_results.clear();
|
||||
}
|
||||
|
||||
static void reset_updated(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.last_updated_user = 0;
|
||||
s.last_updated_channel = 0;
|
||||
}
|
||||
|
||||
static bool wait_updated(EventStore& s, uint32_t user_id, int timeout_ms) {
|
||||
return wait_for(s, [user_id](EventStore& st) {
|
||||
return st.last_updated_user == user_id;
|
||||
}, timeout_ms);
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m5_mod_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
|
||||
if (!db.create_account("admin", "admin-pass", true, err)) {
|
||||
std::printf("FAIL: create admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
if (!db.create_account("alice", "alice-pass", false, err)) {
|
||||
std::printf("FAIL: create alice: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
if (!db.create_account("bob", "bob-pass", false, err)) {
|
||||
std::printf("FAIL: create bob: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-M5-Mod";
|
||||
cfg.allow_guests = false;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
auto make_client = [&](const char* label, const char* user, const char* pass) -> EventStore* {
|
||||
auto* ev = new EventStore();
|
||||
ev->label = label;
|
||||
vc_callbacks cb{on_event, nullptr, ev};
|
||||
vc_config cfgx{label, "0.1", VC_LOG_OFF};
|
||||
ev->client = vc_client_create(&cfgx, cb);
|
||||
if (!ev->client) return nullptr;
|
||||
if (vc_connect(ev->client, "127.0.0.1", port) != VC_OK) return nullptr;
|
||||
if (vc_authenticate_user(ev->client, user, pass) != VC_OK) return nullptr;
|
||||
return ev;
|
||||
};
|
||||
|
||||
EventStore* evAdmin = make_client("admin", "admin", "admin-pass");
|
||||
CHECK(evAdmin != nullptr);
|
||||
CHECK(wait_for(*evAdmin, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(*evAdmin, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t admin_id = evAdmin->self_user_id;
|
||||
CHECK(admin_id != 0);
|
||||
|
||||
EventStore* evAlice = make_client("alice", "alice", "alice-pass");
|
||||
CHECK(evAlice != nullptr);
|
||||
CHECK(wait_for(*evAlice, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(*evAlice, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t alice_id = evAlice->self_user_id;
|
||||
CHECK(alice_id != 0);
|
||||
|
||||
EventStore* evBob = make_client("bob", "bob", "bob-pass");
|
||||
CHECK(evBob != nullptr);
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t bob_id = evBob->self_user_id;
|
||||
CHECK(bob_id != 0);
|
||||
|
||||
// Kick bob.
|
||||
reset_generic(*evAdmin);
|
||||
CHECK(vc_kick_user(evAdmin->client, bob_id, "bye bob") == VC_OK);
|
||||
CHECK(wait_generic(*evAdmin, 3000));
|
||||
CHECK(last_generic_ok(*evAdmin));
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.disconnected; }, 3000));
|
||||
CHECK(evBob->disconnect_reason == VC_ERR_IO);
|
||||
vc_client_destroy(evBob->client);
|
||||
delete evBob;
|
||||
|
||||
// Reconnect bob.
|
||||
evBob = make_client("bob", "bob", "bob-pass");
|
||||
CHECK(evBob != nullptr);
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
bob_id = evBob->self_user_id;
|
||||
CHECK(bob_id != 0);
|
||||
|
||||
// Ban bob permanently.
|
||||
reset_generic(*evAdmin);
|
||||
CHECK(vc_ban_user(evAdmin->client, bob_id, "spam", 0) == VC_OK);
|
||||
CHECK(wait_generic(*evAdmin, 3000));
|
||||
CHECK(last_generic_ok(*evAdmin));
|
||||
CHECK(wait_for(*evBob, [](EventStore& s){ return s.disconnected; }, 3000));
|
||||
vc_client_destroy(evBob->client);
|
||||
delete evBob;
|
||||
|
||||
// Bob tries to reconnect — should fail auth due to username ban.
|
||||
{
|
||||
EventStore evBanned;
|
||||
evBanned.label = "banned-bob";
|
||||
vc_callbacks cb{on_event, nullptr, &evBanned};
|
||||
vc_config cfgb{"banned-bob", "0.1", VC_LOG_OFF};
|
||||
vc_client* banned_bob = vc_client_create(&cfgb, cb);
|
||||
evBanned.client = banned_bob;
|
||||
CHECK(banned_bob != nullptr);
|
||||
CHECK(vc_connect(banned_bob, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(banned_bob, "bob", "bob-pass") == VC_OK);
|
||||
CHECK(wait_for(evBanned, [](EventStore& s){ return s.auth_done || s.disconnected; }, 20000));
|
||||
CHECK(!evBanned.auth_ok); // banned
|
||||
vc_disconnect(banned_bob);
|
||||
vc_client_destroy(banned_bob);
|
||||
}
|
||||
|
||||
// Move alice to Music Room (channel id 2).
|
||||
reset_generic(*evAdmin);
|
||||
reset_updated(*evAlice);
|
||||
CHECK(vc_move_user(evAdmin->client, alice_id, 2) == VC_OK);
|
||||
CHECK(wait_generic(*evAdmin, 3000));
|
||||
CHECK(last_generic_ok(*evAdmin));
|
||||
CHECK(wait_updated(*evAlice, alice_id, 3000));
|
||||
{
|
||||
vc_channel_list cl{};
|
||||
CHECK(vc_list_channels(evAlice->client, &cl) == VC_OK);
|
||||
CHECK(cl.count >= 2);
|
||||
vc_free_channel_list(&cl);
|
||||
vc_user_list ul{};
|
||||
CHECK(vc_list_users(evAlice->client, &ul) == VC_OK);
|
||||
bool found_alice = false;
|
||||
for (size_t i = 0; i < ul.count; ++i) {
|
||||
if (ul.items[i].id == alice_id) {
|
||||
found_alice = true;
|
||||
CHECK(ul.items[i].channel_id == 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
CHECK(found_alice);
|
||||
vc_free_user_list(&ul);
|
||||
}
|
||||
|
||||
// Server-mute alice.
|
||||
reset_generic(*evAdmin);
|
||||
reset_updated(*evAlice);
|
||||
CHECK(vc_set_server_mute(evAdmin->client, alice_id, true, false) == VC_OK);
|
||||
CHECK(wait_generic(*evAdmin, 3000));
|
||||
CHECK(last_generic_ok(*evAdmin));
|
||||
CHECK(wait_updated(*evAlice, alice_id, 3000));
|
||||
|
||||
// Cleanup.
|
||||
vc_disconnect(evAdmin->client);
|
||||
vc_disconnect(evAlice->client);
|
||||
vc_client_destroy(evAdmin->client);
|
||||
vc_client_destroy(evAlice->client);
|
||||
delete evAdmin;
|
||||
delete evAlice;
|
||||
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m5_kick_ban_move_mute: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m5_kick_ban_move_mute: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
#else // !VOICECAT_HAS_NET
|
||||
|
||||
int main() {
|
||||
std::printf("m5_kick_ban_move_mute: SKIP (VOICECAT_HAS_NET not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
266
tests/test_m5_permissions.cpp
Normal file
266
tests/test_m5_permissions.cpp
Normal file
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* test_m5_permissions — Phase 1 of M5: server-side permission enforcement.
|
||||
*
|
||||
* Verifies:
|
||||
* - admin gets is_admin/can_create_temp_channel/etc.
|
||||
* - normal password user gets no permissions.
|
||||
* - admin can create a channel.
|
||||
* - normal user cannot create a channel.
|
||||
* - admin can grant can_create_temp_channel via vc_set_permission.
|
||||
* - granted normal user can now create a channel.
|
||||
*/
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "voicecat.h"
|
||||
#include "server.h"
|
||||
#include "db.h"
|
||||
|
||||
struct EventStore {
|
||||
std::mutex mu;
|
||||
std::condition_variable cv;
|
||||
|
||||
bool auth_ok{false};
|
||||
uint32_t self_user_id{0};
|
||||
bool channel_list_received{false};
|
||||
bool disconnected{false};
|
||||
|
||||
// VC_EVENT_GENERIC_RESULT tracking
|
||||
struct ResultAck {
|
||||
bool ok{false};
|
||||
uint32_t code{0};
|
||||
std::string message;
|
||||
};
|
||||
std::vector<ResultAck> generic_results;
|
||||
|
||||
vc_client* client{nullptr};
|
||||
const char* label{nullptr};
|
||||
};
|
||||
|
||||
static void on_event(void* user, const vc_event* ev) {
|
||||
auto* s = static_cast<EventStore*>(user);
|
||||
std::lock_guard lk(s->mu);
|
||||
switch (ev->type) {
|
||||
case VC_EVENT_SERVER_IDENTITY:
|
||||
vc_confirm_server_identity(s->client, 1);
|
||||
break;
|
||||
case VC_EVENT_AUTH_RESULT:
|
||||
s->auth_ok = (ev->result == VC_OK);
|
||||
s->self_user_id = ev->user_id;
|
||||
break;
|
||||
case VC_EVENT_CHANNEL_LIST:
|
||||
s->channel_list_received = true;
|
||||
break;
|
||||
case VC_EVENT_GENERIC_RESULT: {
|
||||
EventStore::ResultAck gr;
|
||||
gr.ok = (ev->result == VC_OK);
|
||||
gr.code = ev->u32a;
|
||||
gr.message = ev->text ? ev->text : "";
|
||||
s->generic_results.push_back(std::move(gr));
|
||||
break;
|
||||
}
|
||||
case VC_EVENT_DISCONNECTED:
|
||||
s->disconnected = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
s->cv.notify_all();
|
||||
}
|
||||
|
||||
template<typename Pred>
|
||||
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
std::unique_lock lk(s.mu);
|
||||
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||
}
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool wait_generic(EventStore& s, int timeout_ms) {
|
||||
return wait_for(s, [](EventStore& st) { return !st.generic_results.empty(); }, timeout_ms);
|
||||
}
|
||||
|
||||
static bool last_generic_ok(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
return !s.generic_results.empty() && s.generic_results.back().ok;
|
||||
}
|
||||
|
||||
static void reset_generic(EventStore& s) {
|
||||
std::lock_guard lk(s.mu);
|
||||
s.generic_results.clear();
|
||||
}
|
||||
|
||||
int main() {
|
||||
auto tmp = std::filesystem::temp_directory_path() /
|
||||
("vctest_m5_perm_" + std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
std::filesystem::create_directories(tmp);
|
||||
std::string data_dir = tmp.string();
|
||||
|
||||
// Pre-provision admin and normal user.
|
||||
{
|
||||
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||
std::string err;
|
||||
if (!db.open(err)) { std::printf("FAIL: db.open: %s\n", err.c_str()); return 1; }
|
||||
if (!db.create_account("admin", "admin-pass", true, err)) {
|
||||
std::printf("FAIL: create admin: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
if (!db.create_account("bob", "bob-pass", false, err)) {
|
||||
std::printf("FAIL: create bob: %s\n", err.c_str());
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<uint16_t> bound_port{0};
|
||||
std::mutex ready_mu;
|
||||
std::condition_variable ready_cv;
|
||||
bool ready{false};
|
||||
|
||||
voicecat::server::Config cfg;
|
||||
cfg.data_dir = data_dir;
|
||||
cfg.bind_port = 0;
|
||||
cfg.server_name = "VoiceCat-M5-Perm";
|
||||
cfg.allow_guests = false;
|
||||
cfg.on_ready = [&](uint16_t p) {
|
||||
bound_port.store(p);
|
||||
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||
ready_cv.notify_all();
|
||||
};
|
||||
|
||||
voicecat::server::Server server(cfg);
|
||||
std::thread server_thread([&] { server.run(); });
|
||||
|
||||
{
|
||||
std::unique_lock lk(ready_mu);
|
||||
if (!ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; })) {
|
||||
std::printf("FAIL: server did not become ready\n");
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
uint16_t port = bound_port.load();
|
||||
|
||||
// Admin client.
|
||||
EventStore evA;
|
||||
evA.label = "admin";
|
||||
vc_callbacks cbA{on_event, nullptr, &evA};
|
||||
vc_config cfgA{"test-admin", "0.1", VC_LOG_OFF};
|
||||
vc_client* admin = vc_client_create(&cfgA, cbA);
|
||||
evA.client = admin;
|
||||
CHECK(admin != nullptr);
|
||||
CHECK(vc_connect(admin, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(admin, "admin", "admin-pass") == VC_OK);
|
||||
CHECK(wait_for(evA, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evA, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t admin_id = evA.self_user_id;
|
||||
CHECK(admin_id != 0);
|
||||
|
||||
// Normal client.
|
||||
EventStore evB;
|
||||
evB.label = "bob";
|
||||
vc_callbacks cbB{on_event, nullptr, &evB};
|
||||
vc_config cfgB{"test-bob", "0.1", VC_LOG_OFF};
|
||||
vc_client* bob = vc_client_create(&cfgB, cbB);
|
||||
evB.client = bob;
|
||||
CHECK(bob != nullptr);
|
||||
CHECK(vc_connect(bob, "127.0.0.1", port) == VC_OK);
|
||||
CHECK(vc_authenticate_user(bob, "bob", "bob-pass") == VC_OK);
|
||||
CHECK(wait_for(evB, [](EventStore& s){ return s.auth_ok; }, 20000));
|
||||
CHECK(wait_for(evB, [](EventStore& s){ return s.channel_list_received; }, 3000));
|
||||
uint32_t bob_id = evB.self_user_id;
|
||||
CHECK(bob_id != 0);
|
||||
|
||||
// Verify permissions reported by the core.
|
||||
vc_permissions admin_perms{};
|
||||
CHECK(vc_get_permissions(admin, &admin_perms) == VC_OK);
|
||||
CHECK(admin_perms.is_admin != 0);
|
||||
CHECK(admin_perms.can_create_temp_channel != 0);
|
||||
|
||||
vc_permissions bob_perms{};
|
||||
CHECK(vc_get_permissions(bob, &bob_perms) == VC_OK);
|
||||
CHECK(bob_perms.is_admin == 0);
|
||||
CHECK(bob_perms.can_create_temp_channel == 0);
|
||||
|
||||
// Admin creates a channel.
|
||||
vc_channel_info ch{};
|
||||
ch.name = "Admin Channel";
|
||||
ch.topic = "created by admin";
|
||||
ch.max_users = 0;
|
||||
ch.sort_order = 10;
|
||||
CHECK(vc_create_channel(admin, &ch) == VC_OK);
|
||||
CHECK(wait_generic(evA, 3000));
|
||||
CHECK(last_generic_ok(evA));
|
||||
|
||||
// Normal user tries to create a channel — should be denied.
|
||||
reset_generic(evB);
|
||||
vc_channel_info ch2{};
|
||||
ch2.name = "Bob Channel";
|
||||
ch2.topic = "created by bob";
|
||||
ch2.sort_order = 11;
|
||||
CHECK(vc_create_channel(bob, &ch2) == VC_OK);
|
||||
CHECK(wait_generic(evB, 3000));
|
||||
CHECK(!last_generic_ok(evB));
|
||||
|
||||
// Admin grants can_create_temp_channel to bob.
|
||||
reset_generic(evA);
|
||||
vc_permissions grant{};
|
||||
grant.can_create_temp_channel = 1;
|
||||
CHECK(vc_set_permission(admin, bob_id, &grant) == VC_OK);
|
||||
CHECK(wait_generic(evA, 3000));
|
||||
CHECK(last_generic_ok(evA));
|
||||
|
||||
// Bob creates a channel — should succeed now.
|
||||
reset_generic(evB);
|
||||
CHECK(vc_create_channel(bob, &ch2) == VC_OK);
|
||||
CHECK(wait_generic(evB, 3000));
|
||||
CHECK(last_generic_ok(evB));
|
||||
|
||||
// Cleanup.
|
||||
vc_disconnect(admin);
|
||||
vc_disconnect(bob);
|
||||
vc_client_destroy(admin);
|
||||
vc_client_destroy(bob);
|
||||
server.stop();
|
||||
server_thread.join();
|
||||
std::filesystem::remove_all(tmp);
|
||||
|
||||
if (g_failures == 0) {
|
||||
std::printf("m5_permissions: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("m5_permissions: %d failure(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
#else // !VOICECAT_HAS_NET
|
||||
|
||||
int main() {
|
||||
std::printf("m5_permissions: SKIP (VOICECAT_HAS_NET not defined)\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
@@ -23,12 +23,22 @@ std::atomic<bool> g_stop{false};
|
||||
void on_sigint(int) { g_stop.store(true); }
|
||||
|
||||
struct Stats {
|
||||
std::atomic<bool> auth_done{false};
|
||||
std::atomic<bool> auth_ok{false};
|
||||
std::atomic<bool> auth_done{false};
|
||||
std::atomic<bool> 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<int> generic_result_count{0};
|
||||
std::atomic<int> last_result{0};
|
||||
std::atomic<uint32_t> last_code{0};
|
||||
std::string last_message;
|
||||
std::mutex last_message_mu;
|
||||
std::atomic<bool> account_list_received{false};
|
||||
};
|
||||
|
||||
void on_event(void* user, const vc_event* ev) {
|
||||
@@ -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<bool>& 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<vc_result>(st.last_result.load());
|
||||
}
|
||||
|
||||
// Parse "true"/"false"/"1"/"0"/"yes"/"no" (case-insensitive).
|
||||
bool parse_bool(const char* s, bool* out) {
|
||||
if (!s || !*s) return false;
|
||||
std::string v = s;
|
||||
for (auto& ch : v) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
if (v == "1" || v == "true" || v == "yes" || v == "on") { *out = true; return true; }
|
||||
if (v == "0" || v == "false" || v == "no" || v == "off") { *out = false; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
bool parse_u32(const char* s, uint32_t* out, const char* label) {
|
||||
if (!s || !*s) { std::fprintf(stderr, "missing value for %s\n", label); return false; }
|
||||
try {
|
||||
*out = static_cast<uint32_t>(std::stoul(s));
|
||||
return true;
|
||||
} catch (...) {
|
||||
std::fprintf(stderr, "invalid value for %s: %s\n", label, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool parse_u16(const char* s, uint16_t* out, const char* label) {
|
||||
if (!s || !*s) { std::fprintf(stderr, "missing value for %s\n", label); return false; }
|
||||
try {
|
||||
int v = std::stoi(s);
|
||||
if (v < 0 || v > 65535) throw std::out_of_range("port");
|
||||
*out = static_cast<uint16_t>(v);
|
||||
return true;
|
||||
} catch (...) {
|
||||
std::fprintf(stderr, "invalid value for %s: %s\n", label, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void print_usage() {
|
||||
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<bool>& stop) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue an M5 request that produces VC_EVENT_GENERIC_RESULT and wait for it.
|
||||
// Returns the result code from the event.
|
||||
using RequestFn = std::function<vc_result()>;
|
||||
|
||||
vc_result run_generic_request(Stats& st, int timeout_ms, RequestFn fn, const char* label) {
|
||||
int before = st.generic_result_count.load();
|
||||
vc_result r = fn();
|
||||
std::printf("%s -> %d (%s)\n", label, r, vc_result_string(r));
|
||||
if (r != VC_OK) return r;
|
||||
vc_result event_rc = wait_generic_result(st, before, timeout_ms);
|
||||
if (event_rc == VC_ERR_TIMEOUT) {
|
||||
std::fprintf(stderr, "%s: timed out waiting for result event\n", label);
|
||||
return VC_ERR_TIMEOUT;
|
||||
}
|
||||
return event_rc;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
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<uint16_t>(std::stoi(next()));
|
||||
else if (a == "--port") { if (!parse_u16(next().c_str(), &port, "--port")) return 1; }
|
||||
else if (a == "--nick") nick = next();
|
||||
else if (a == "--channel") channel_id = static_cast<uint32_t>(std::stoul(next()));
|
||||
else if (a == "--username") { username = next(); have_username = true; }
|
||||
else if (a == "--password") { password = next(); have_password = true; }
|
||||
else if (a == "--channel") { if (!parse_u32(next().c_str(), &channel_id, "--channel")) return 1; }
|
||||
else if (a == "--voice") voice_mode = true;
|
||||
else if (a == "--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<uint64_t>(std::stoull(v)); } catch (...) {
|
||||
std::fprintf(stderr, "invalid --ban-expires-ms: %s\n", v.c_str()); return 1;
|
||||
}
|
||||
}
|
||||
else if (a == "--move") { do_move = true; if (!parse_u32(next().c_str(), &move_user_id, "--move")) return 1; }
|
||||
else if (a == "--to-channel") { if (!parse_u32(next().c_str(), &move_channel_id, "--to-channel")) return 1; }
|
||||
else if (a == "--server-mute") { do_server_mute = true; server_mute_muted = 1; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-mute")) return 1; }
|
||||
else if (a == "--server-unmute") { do_server_unmute = true; server_mute_muted = 0; server_mute_deafened = 0; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-unmute")) return 1; }
|
||||
else if (a == "--server-deafen") { do_server_deafen = true; server_mute_muted = 1; server_mute_deafened = 1; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-deafen")) return 1; }
|
||||
else if (a == "--server-undeafen") { do_server_undeafen = true; server_mute_muted = 0; server_mute_deafened = 0; if (!parse_u32(next().c_str(), &server_mute_user_id, "--server-undeafen")) return 1; }
|
||||
// Permissions
|
||||
else if (a == "--set-permission") { do_set_permission = true; if (!parse_u32(next().c_str(), &perm_user_id, "--set-permission")) return 1; }
|
||||
else if (a == "--perm-admin") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.is_admin = b; }
|
||||
else if (a == "--perm-kick") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_kick = b; }
|
||||
else if (a == "--perm-ban") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_ban = b; }
|
||||
else if (a == "--perm-move") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_move_users = b; }
|
||||
else if (a == "--perm-create-temp") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_create_temp_channel = b; }
|
||||
else if (a == "--perm-admin-accounts") { bool b; if (!parse_bool(next().c_str(), &b)) return 1; perms.can_admin_accounts = b; }
|
||||
// Channel CRUD
|
||||
else if (a == "--create-channel") do_create_channel = true;
|
||||
else if (a == "--edit-channel") do_edit_channel = true;
|
||||
else if (a == "--delete-channel") { do_delete_channel = true; if (!parse_u32(next().c_str(), &delete_channel_id, "--delete-channel")) return 1; }
|
||||
else if (a == "--channel-id") { if (!parse_u32(next().c_str(), &channel_info.id, "--channel-id")) return 1; }
|
||||
else if (a == "--new-channel-name") channel_info.name = next().c_str();
|
||||
else if (a == "--new-channel-topic") channel_info.topic = next().c_str();
|
||||
else if (a == "--new-channel-parent") { if (!parse_u32(next().c_str(), &channel_info.parent_id, "--new-channel-parent")) return 1; }
|
||||
else if (a == "--new-channel-password") {
|
||||
channel_info.password = next().c_str();
|
||||
channel_info.password_protected = 1;
|
||||
}
|
||||
else if (a == "--new-channel-max-users") { if (!parse_u32(next().c_str(), &channel_info.max_users, "--new-channel-max-users")) return 1; }
|
||||
// Account management
|
||||
else if (a == "--create-account") { do_create_account = true; acct_user = next(); acct_pass = next(); }
|
||||
else if (a == "--reset-password") { do_reset_password = true; acct_user = next(); acct_pass = next(); }
|
||||
else if (a == "--delete-account") { do_delete_account = true; acct_user = next(); }
|
||||
else if (a == "--list-accounts") do_list_accounts = true;
|
||||
else if (a == "--help" || a == "-h") { print_usage(); return 0; }
|
||||
else { 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user