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

@@ -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();