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

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

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

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

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

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

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

View File

@@ -10,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;