feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text
Implements the full M1 milestone. Two clients authenticate over TLS 1.3 (guest + Argon2id password) and exchange channel + private text messages through a real server. All five ctest --preset m1-dev tests pass in ~1 s. Key components added: - vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3) - FrameCodec feed+emit, encode/decode_envelope, protobuf codegen - TcpServerConn with blocking TLS handshake thread + tls_read_loop - TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client) - WorkerPool (3 threads, used for Argon2id) - Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin - ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint - ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated - SessionRegistry: channel tree, user map, text routing, broadcast - vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect - voicecat-admin CLI: account add/reset/del/list - test_m1_integration: M1 exit criterion, verified green Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope was adding the [4-byte len] prefix, then TcpServerConn::send_frame added a second one, causing the client to parse [len][proto] as protobuf (silent failure). Fixed by serializing raw protobuf bytes in send_envelope and letting send_frame apply the single length prefix. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,33 @@
|
||||
file(GLOB_RECURSE VOICECAT_SERVER_SOURCES CONFIGURE_DEPENDS
|
||||
# voicecat-server-lib — all server implementation except main.cpp.
|
||||
# Linked by the server binary AND by tests (test_m1_integration).
|
||||
file(GLOB_RECURSE VOICECAT_SERVER_LIB_SOURCES CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
|
||||
list(FILTER VOICECAT_SERVER_LIB_SOURCES EXCLUDE REGEX ".*[/\\\\]main\\.cpp$")
|
||||
|
||||
add_executable(voicecat-server ${VOICECAT_SERVER_SOURCES})
|
||||
target_link_libraries(voicecat-server PRIVATE voicecat::voicecat)
|
||||
add_library(voicecat-server-lib STATIC ${VOICECAT_SERVER_LIB_SOURCES})
|
||||
target_compile_features(voicecat-server-lib PRIVATE cxx_std_20)
|
||||
target_link_libraries(voicecat-server-lib PUBLIC voicecat::voicecat)
|
||||
target_include_directories(voicecat-server-lib PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/core/src)
|
||||
add_library(voicecat::server ALIAS voicecat-server-lib)
|
||||
|
||||
if(VOICECAT_USE_VCPKG_DEPS)
|
||||
find_package(unofficial-sqlite3 CONFIG REQUIRED)
|
||||
find_package(unofficial-sodium CONFIG REQUIRED)
|
||||
find_package(MbedTLS CONFIG REQUIRED)
|
||||
find_package(asio CONFIG REQUIRED)
|
||||
target_link_libraries(voicecat-server-lib PUBLIC
|
||||
unofficial::sqlite3::sqlite3
|
||||
unofficial-sodium::sodium
|
||||
MbedTLS::mbedtls MbedTLS::mbedcrypto MbedTLS::mbedx509
|
||||
asio::asio)
|
||||
if(WIN32)
|
||||
target_link_libraries(voicecat-server-lib PUBLIC ws2_32 mswsock)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# The server binary is just main.cpp calling Server::run().
|
||||
add_executable(voicecat-server ${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp)
|
||||
target_link_libraries(voicecat-server PRIVATE voicecat::server)
|
||||
target_compile_features(voicecat-server PRIVATE cxx_std_20)
|
||||
|
||||
261
server/src/conn_session.cpp
Normal file
261
server/src/conn_session.cpp
Normal file
@@ -0,0 +1,261 @@
|
||||
#include "conn_session.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
|
||||
#include "db.h"
|
||||
#include "session_registry.h"
|
||||
#include "core/worker_pool.h"
|
||||
#include "protocol/envelope.h"
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
static voicecat::v1::Envelope make_env(uint64_t req_id = 0) {
|
||||
voicecat::v1::Envelope e;
|
||||
e.set_request_id(req_id);
|
||||
return e;
|
||||
}
|
||||
|
||||
ConnSession::ConnSession(std::shared_ptr<Database> db,
|
||||
std::shared_ptr<SessionRegistry> registry,
|
||||
std::shared_ptr<voicecat::WorkerPool> workers,
|
||||
const std::array<uint8_t, 32>& server_fp,
|
||||
bool allow_guests)
|
||||
: db_(std::move(db)),
|
||||
registry_(std::move(registry)),
|
||||
workers_(std::move(workers)),
|
||||
server_fp_(server_fp),
|
||||
allow_guests_(allow_guests) {}
|
||||
|
||||
void ConnSession::set_io(SendFn send_fn, CloseFn close_fn) {
|
||||
send_fn_ = std::move(send_fn);
|
||||
close_fn_ = std::move(close_fn);
|
||||
}
|
||||
|
||||
void ConnSession::begin() {
|
||||
// Nothing to do at TCP level — wait for ClientHello
|
||||
}
|
||||
|
||||
void ConnSession::on_frame(std::vector<uint8_t> frame) {
|
||||
voicecat::v1::Envelope env;
|
||||
if (!protocol::decode_envelope(frame, env)) return;
|
||||
|
||||
auto st = state_.load(std::memory_order_acquire);
|
||||
switch (env.body_case()) {
|
||||
case voicecat::v1::Envelope::kClientHello:
|
||||
if (st == State::WaitingHello)
|
||||
handle_client_hello(env.request_id(), env.client_hello());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kAuthRequest:
|
||||
if (st == State::WaitingAuth)
|
||||
handle_auth_request(env.request_id(), env.auth_request());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kJoinChannel:
|
||||
if (st == State::Authenticated)
|
||||
handle_join_channel(env.request_id(), env.join_channel());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kTextMessage:
|
||||
if (st == State::Authenticated)
|
||||
handle_text_message(env.text_message());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kPing:
|
||||
handle_ping(env.ping());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kLeaveChannel:
|
||||
if (st == State::Authenticated)
|
||||
registry_->set_user_channel(user_id_.load(), 1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void ConnSession::on_disconnect() { close(); }
|
||||
|
||||
void ConnSession::send_envelope(const voicecat::v1::Envelope& env) {
|
||||
if (!send_fn_ || closed_.load()) return;
|
||||
// Serialize to raw protobuf bytes; send_fn_ (→ TcpServerConn::send_frame)
|
||||
// adds the [4-byte len] framing, so we must NOT pre-frame here.
|
||||
std::string bytes;
|
||||
if (!env.SerializeToString(&bytes)) return;
|
||||
std::vector<uint8_t> raw(bytes.begin(), bytes.end());
|
||||
send_fn_(std::move(raw));
|
||||
}
|
||||
|
||||
void ConnSession::close() {
|
||||
if (closed_.exchange(true)) return;
|
||||
state_.store(State::Disconnecting, std::memory_order_release);
|
||||
uint32_t uid = user_id_.load();
|
||||
if (uid) registry_->remove_user(uid);
|
||||
if (session_id_) registry_->unregister_session(session_id_);
|
||||
if (close_fn_) close_fn_();
|
||||
}
|
||||
|
||||
void ConnSession::handle_client_hello(uint64_t req_id, const voicecat::v1::ClientHello& msg) {
|
||||
if (msg.proto_version() != 1) {
|
||||
send_disconnect_and_close(1, "unsupported protocol version");
|
||||
return;
|
||||
}
|
||||
auto env = make_env(req_id);
|
||||
auto* hello = env.mutable_server_hello();
|
||||
hello->set_proto_version(1);
|
||||
hello->set_server_name("VoiceCat Server");
|
||||
hello->set_server_version("0.1.0");
|
||||
if (allow_guests_) hello->add_auth_methods("guest");
|
||||
hello->add_auth_methods("password");
|
||||
hello->set_server_identity_fingerprint(server_fp_.data(), server_fp_.size());
|
||||
send_envelope(env);
|
||||
state_.store(State::WaitingAuth, std::memory_order_release);
|
||||
}
|
||||
|
||||
void ConnSession::handle_auth_request(uint64_t req_id, const voicecat::v1::AuthRequest& msg) {
|
||||
if (msg.has_guest()) {
|
||||
finish_guest_auth(msg.guest(), req_id);
|
||||
} else if (msg.has_password()) {
|
||||
finish_password_auth(msg.password().username(), msg.password().password(), req_id);
|
||||
} else {
|
||||
auto env = make_env(req_id);
|
||||
env.mutable_auth_result()->set_ok(false);
|
||||
env.mutable_auth_result()->set_error("unknown auth method");
|
||||
send_envelope(env);
|
||||
}
|
||||
}
|
||||
|
||||
void ConnSession::finish_guest_auth(const voicecat::v1::GuestAuth& guest, uint64_t req_id) {
|
||||
if (!allow_guests_) {
|
||||
auto env = make_env(req_id);
|
||||
env.mutable_auth_result()->set_ok(false);
|
||||
env.mutable_auth_result()->set_error("guest login not permitted");
|
||||
send_envelope(env);
|
||||
return;
|
||||
}
|
||||
voicecat::v1::User user;
|
||||
user.set_nickname(guest.nickname().empty() ? "Guest" : guest.nickname());
|
||||
user.set_is_guest(true);
|
||||
user.set_channel_id(1);
|
||||
|
||||
uint32_t uid = registry_->add_user(session_id_, user);
|
||||
user.set_id(uid);
|
||||
user_id_.store(uid, std::memory_order_relaxed);
|
||||
state_.store(State::Authenticated, std::memory_order_release);
|
||||
|
||||
{
|
||||
auto env = make_env(req_id);
|
||||
auto* res = env.mutable_auth_result();
|
||||
res->set_ok(true);
|
||||
res->set_session_id(session_id_);
|
||||
*res->mutable_self() = user;
|
||||
send_envelope(env);
|
||||
}
|
||||
broadcast_user_joined(user);
|
||||
send_state_snapshot();
|
||||
}
|
||||
|
||||
void ConnSession::finish_password_auth(const std::string& username,
|
||||
const std::string& password, uint64_t req_id) {
|
||||
// Argon2id runs on the worker pool (deliberately slow).
|
||||
auto self = shared_from_this();
|
||||
workers_->post([self, username, password, req_id] {
|
||||
auto acc = self->db_->authenticate(username, password);
|
||||
if (!acc) {
|
||||
auto env = make_env(req_id);
|
||||
env.mutable_auth_result()->set_ok(false);
|
||||
env.mutable_auth_result()->set_error("invalid credentials");
|
||||
self->send_envelope(env);
|
||||
return;
|
||||
}
|
||||
voicecat::v1::User user;
|
||||
user.set_nickname(acc->username);
|
||||
user.set_is_guest(false);
|
||||
user.set_channel_id(1);
|
||||
|
||||
uint32_t uid = self->registry_->add_user(self->session_id_, user);
|
||||
user.set_id(uid);
|
||||
self->user_id_.store(uid, std::memory_order_relaxed);
|
||||
self->state_.store(State::Authenticated, std::memory_order_release);
|
||||
|
||||
{
|
||||
auto env = make_env(req_id);
|
||||
auto* res = env.mutable_auth_result();
|
||||
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);
|
||||
self->send_envelope(env);
|
||||
}
|
||||
self->broadcast_user_joined(user);
|
||||
self->send_state_snapshot();
|
||||
});
|
||||
}
|
||||
|
||||
void ConnSession::send_state_snapshot() {
|
||||
auto env = make_env();
|
||||
auto* snap = env.mutable_server_state();
|
||||
for (auto& ch : registry_->channel_snapshot()) *snap->add_channels() = ch;
|
||||
for (auto& u : registry_->user_snapshot()) *snap->add_users() = u;
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
void ConnSession::broadcast_user_joined(const voicecat::v1::User& user) {
|
||||
auto bcast = make_env();
|
||||
auto* ue = bcast.mutable_user_event();
|
||||
ue->set_kind(voicecat::v1::UserEvent::JOINED);
|
||||
*ue->mutable_user() = user;
|
||||
registry_->broadcast(bcast, session_id_);
|
||||
}
|
||||
|
||||
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());
|
||||
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());
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
void ConnSession::handle_text_message(const voicecat::v1::TextMessage& msg) {
|
||||
using namespace std::chrono;
|
||||
int64_t now_ms = duration_cast<milliseconds>(
|
||||
system_clock::now().time_since_epoch()).count();
|
||||
|
||||
voicecat::v1::TextMessage relay = msg;
|
||||
relay.set_sender_id(user_id_.load(std::memory_order_relaxed));
|
||||
relay.set_sent_at_unix_ms(now_ms);
|
||||
|
||||
voicecat::v1::Envelope fwd;
|
||||
*fwd.mutable_text_message() = relay;
|
||||
|
||||
auto targets = registry_->resolve_text_targets(session_id_, msg.scope(), msg.target_id());
|
||||
for (auto& t : targets) t->send_envelope(fwd);
|
||||
|
||||
// Ack
|
||||
auto env = make_env();
|
||||
auto* ack = env.mutable_text_message_ack();
|
||||
ack->set_client_msg_id(msg.client_msg_id());
|
||||
ack->set_ok(true);
|
||||
send_envelope(env);
|
||||
}
|
||||
|
||||
void ConnSession::handle_ping(const voicecat::v1::Ping& msg) {
|
||||
auto env = make_env();
|
||||
env.mutable_pong()->set_nonce(msg.nonce());
|
||||
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();
|
||||
d->set_code(code);
|
||||
d->set_reason(reason);
|
||||
send_envelope(env);
|
||||
close();
|
||||
}
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
101
server/src/conn_session.h
Normal file
101
server/src/conn_session.h
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* server/conn_session.h — Per-client connection state machine.
|
||||
*
|
||||
* State: WaitingHello → WaitingAuth → Authenticated → Disconnecting
|
||||
*
|
||||
* Design: ConnSession is a pure state machine. It receives frames via on_frame()
|
||||
* (called from TcpServerConn's strand) and sends via a send_fn set after construction.
|
||||
* The server creates the TcpServerConn first (with callbacks referencing the session),
|
||||
* then calls set_tcp() to give the session its send capability.
|
||||
*/
|
||||
#ifndef VOICECAT_SERVER_CONN_SESSION_H
|
||||
#define VOICECAT_SERVER_CONN_SESSION_H
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "proto/voicecat.pb.h"
|
||||
|
||||
namespace voicecat { class WorkerPool; } // defined in core/worker_pool.h
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
class Database;
|
||||
class SessionRegistry;
|
||||
|
||||
class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
||||
public:
|
||||
enum class State { WaitingHello, WaitingAuth, Authenticated, Disconnecting };
|
||||
|
||||
using SendFn = std::function<void(std::vector<uint8_t>)>;
|
||||
using CloseFn = std::function<void()>;
|
||||
|
||||
ConnSession(std::shared_ptr<Database> db,
|
||||
std::shared_ptr<SessionRegistry> registry,
|
||||
std::shared_ptr<voicecat::WorkerPool> workers,
|
||||
const std::array<uint8_t, 32>& server_fp,
|
||||
bool allow_guests);
|
||||
|
||||
// Called after construction: gives the session its send + close handles.
|
||||
void set_io(SendFn send_fn, CloseFn close_fn);
|
||||
|
||||
// Called by server after it has registered the session id.
|
||||
void set_session_id(uint64_t id) { session_id_ = id; }
|
||||
|
||||
// Entry point: send ServerHello and begin reading.
|
||||
void begin();
|
||||
|
||||
// Deliver a received frame (called from TcpServerConn's strand).
|
||||
void on_frame(std::vector<uint8_t> frame);
|
||||
|
||||
// Called when the TCP connection drops.
|
||||
void on_disconnect();
|
||||
|
||||
// Thread-safe send.
|
||||
void send_envelope(const voicecat::v1::Envelope& env);
|
||||
|
||||
// Graceful close (can be called from any thread).
|
||||
void close();
|
||||
|
||||
State state() const { return state_.load(); }
|
||||
uint64_t session_id() const { return session_id_; }
|
||||
uint32_t user_id() const { return user_id_; }
|
||||
|
||||
private:
|
||||
void handle_client_hello(uint64_t req_id, const voicecat::v1::ClientHello& msg);
|
||||
void handle_auth_request(uint64_t req_id, const voicecat::v1::AuthRequest& msg);
|
||||
void handle_join_channel(uint64_t req_id, const voicecat::v1::JoinChannelRequest& msg);
|
||||
void handle_text_message(const voicecat::v1::TextMessage& msg);
|
||||
void handle_ping(const voicecat::v1::Ping& 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,
|
||||
uint64_t req_id);
|
||||
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);
|
||||
|
||||
std::shared_ptr<Database> db_;
|
||||
std::shared_ptr<SessionRegistry> registry_;
|
||||
std::shared_ptr<voicecat::WorkerPool> workers_;
|
||||
std::array<uint8_t, 32> server_fp_;
|
||||
bool allow_guests_;
|
||||
|
||||
SendFn send_fn_;
|
||||
CloseFn close_fn_;
|
||||
std::atomic<State> state_{State::WaitingHello};
|
||||
uint64_t session_id_{0}; // set once before begin(), then read-only
|
||||
std::atomic<uint32_t> user_id_{0};
|
||||
std::atomic<bool> closed_{false};
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_SERVER_CONN_SESSION_H
|
||||
229
server/src/db.cpp
Normal file
229
server/src/db.cpp
Normal file
@@ -0,0 +1,229 @@
|
||||
#include "db.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <stdexcept>
|
||||
|
||||
#include <sodium.h>
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
// ── Schema ────────────────────────────────────────────────────────────────────
|
||||
|
||||
static constexpr const char* kCreateSchema = R"sql(
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
pw_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_login INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
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";
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Database::Database(std::string path) : path_(std::move(path)) {}
|
||||
|
||||
Database::~Database() {
|
||||
if (db_) { sqlite3_close(db_); db_ = nullptr; }
|
||||
}
|
||||
|
||||
bool Database::open(std::string& error) {
|
||||
int rc = sqlite3_open(path_.c_str(), &db_);
|
||||
if (rc != SQLITE_OK) {
|
||||
error = sqlite3_errmsg(db_);
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
sqlite3_busy_timeout(db_, 5000);
|
||||
// WAL mode for concurrency
|
||||
exec("PRAGMA journal_mode=WAL", error);
|
||||
exec("PRAGMA synchronous=NORMAL", error);
|
||||
error.clear();
|
||||
if (!exec(kCreateSchema, error)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::is_empty() {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "SELECT COUNT(*) FROM accounts", -1, &stmt, nullptr);
|
||||
int count = 0;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) count = sqlite3_column_int(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
return count == 0;
|
||||
}
|
||||
|
||||
std::optional<Account> Database::create_account(const std::string& username,
|
||||
const std::string& password,
|
||||
bool is_admin, std::string& error) {
|
||||
if (username.empty() || password.empty()) {
|
||||
error = "username and password must not be empty";
|
||||
return std::nullopt;
|
||||
}
|
||||
// Hash with Argon2id via libsodium
|
||||
char hash[crypto_pwhash_STRBYTES];
|
||||
if (crypto_pwhash_str(hash, password.c_str(), password.size(),
|
||||
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_MEMLIMIT_INTERACTIVE) != 0) {
|
||||
error = "Argon2id hashing failed (OOM?)";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
int64_t now = now_unix();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
int rc = sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO accounts (username, pw_hash, is_admin, created_at) VALUES (?,?,?,?)",
|
||||
-1, &stmt, nullptr);
|
||||
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||||
|
||||
sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, hash, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 3, is_admin ? 1 : 0);
|
||||
sqlite3_bind_int64(stmt, 4, now);
|
||||
rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (rc != SQLITE_DONE) {
|
||||
error = sqlite3_errmsg(db_);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Account acc;
|
||||
acc.id = sqlite3_last_insert_rowid(db_);
|
||||
acc.username = username;
|
||||
acc.is_admin = is_admin;
|
||||
acc.created_at = now;
|
||||
return acc;
|
||||
}
|
||||
|
||||
bool Database::reset_password(const std::string& username, const std::string& new_password,
|
||||
std::string& error) {
|
||||
char hash[crypto_pwhash_STRBYTES];
|
||||
if (crypto_pwhash_str(hash, new_password.c_str(), new_password.size(),
|
||||
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_MEMLIMIT_INTERACTIVE) != 0) {
|
||||
error = "Argon2id hashing failed";
|
||||
return false;
|
||||
}
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "UPDATE accounts SET pw_hash=? WHERE username=?", -1, &stmt, nullptr);
|
||||
sqlite3_bind_text(stmt, 1, hash, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, username.c_str(), -1, SQLITE_TRANSIENT);
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||||
if (sqlite3_changes(db_) == 0) { error = "user not found: " + username; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::delete_account(const std::string& username, std::string& error) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_, "DELETE FROM accounts WHERE username=?", -1, &stmt, nullptr);
|
||||
sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT);
|
||||
int rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||||
if (sqlite3_changes(db_) == 0) { error = "user not found: " + username; return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<Account> Database::list_accounts() {
|
||||
std::vector<Account> result;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_,
|
||||
"SELECT id, username, is_admin, created_at, last_login FROM accounts ORDER BY username",
|
||||
-1, &stmt, nullptr);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
Account acc;
|
||||
acc.id = sqlite3_column_int64(stmt, 0);
|
||||
acc.username = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
acc.is_admin = sqlite3_column_int(stmt, 2) != 0;
|
||||
acc.created_at = sqlite3_column_int64(stmt, 3);
|
||||
acc.last_login = sqlite3_column_int64(stmt, 4);
|
||||
result.push_back(acc);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<Account> Database::authenticate(const std::string& username,
|
||||
const std::string& password) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
sqlite3_prepare_v2(db_,
|
||||
"SELECT id, pw_hash, is_admin, created_at, last_login FROM accounts WHERE username=?",
|
||||
-1, &stmt, nullptr);
|
||||
sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT);
|
||||
int rc = sqlite3_step(stmt);
|
||||
if (rc != SQLITE_ROW) { sqlite3_finalize(stmt); return std::nullopt; }
|
||||
|
||||
int64_t id = sqlite3_column_int64(stmt, 0);
|
||||
std::string hash = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
bool is_admin = sqlite3_column_int(stmt, 2) != 0;
|
||||
int64_t created = sqlite3_column_int64(stmt, 3);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
// Verify Argon2id — deliberately slow
|
||||
if (crypto_pwhash_str_verify(hash.c_str(), password.c_str(), password.size()) != 0)
|
||||
return std::nullopt;
|
||||
|
||||
// Update last_login
|
||||
int64_t now = now_unix();
|
||||
sqlite3_stmt* upd = nullptr;
|
||||
sqlite3_prepare_v2(db_, "UPDATE accounts SET last_login=? WHERE id=?", -1, &upd, nullptr);
|
||||
sqlite3_bind_int64(upd, 1, now);
|
||||
sqlite3_bind_int64(upd, 2, id);
|
||||
sqlite3_step(upd);
|
||||
sqlite3_finalize(upd);
|
||||
|
||||
Account acc;
|
||||
acc.id = id;
|
||||
acc.username = username;
|
||||
acc.is_admin = is_admin;
|
||||
acc.created_at = created;
|
||||
acc.last_login = now;
|
||||
return acc;
|
||||
}
|
||||
|
||||
std::string Database::generate_password(size_t length) {
|
||||
static const char kAlphabet[] =
|
||||
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*";
|
||||
constexpr size_t kAlphaLen = sizeof(kAlphabet) - 1;
|
||||
std::string pw;
|
||||
pw.reserve(length);
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
uint8_t rnd[1];
|
||||
randombytes_buf(rnd, 1);
|
||||
pw += kAlphabet[rnd[0] % kAlphaLen];
|
||||
}
|
||||
return pw;
|
||||
}
|
||||
|
||||
bool Database::exec(const std::string& sql, std::string& error) {
|
||||
char* errmsg = nullptr;
|
||||
int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &errmsg);
|
||||
if (rc != SQLITE_OK) {
|
||||
error = errmsg ? errmsg : "unknown error";
|
||||
if (errmsg) sqlite3_free(errmsg);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int64_t Database::now_unix() const {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
80
server/src/db.h
Normal file
80
server/src/db.h
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* server/db.h — SQLite-backed account store with Argon2id password hashing.
|
||||
*
|
||||
* All password operations (hash + verify) are intentionally slow via Argon2id.
|
||||
* Call authenticate() from a WorkerPool thread, never from the net thread.
|
||||
*
|
||||
* Design: docs/security.md §4.
|
||||
*/
|
||||
#ifndef VOICECAT_SERVER_DB_H
|
||||
#define VOICECAT_SERVER_DB_H
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
struct Account {
|
||||
int64_t id{};
|
||||
std::string username;
|
||||
bool is_admin{false};
|
||||
int64_t created_at{};
|
||||
int64_t last_login{};
|
||||
};
|
||||
|
||||
class Database {
|
||||
public:
|
||||
explicit Database(std::string path);
|
||||
~Database();
|
||||
|
||||
Database(const Database&) = delete;
|
||||
Database& operator=(const Database&) = delete;
|
||||
|
||||
// Open the database, run migrations, create tables if needed.
|
||||
// Returns false + sets error on failure.
|
||||
bool open(std::string& error);
|
||||
|
||||
// True if the accounts table has no rows.
|
||||
bool is_empty();
|
||||
|
||||
// Create a new account. Hashes password with Argon2id. Thread-safe.
|
||||
std::optional<Account> create_account(const std::string& username,
|
||||
const std::string& password,
|
||||
bool is_admin, std::string& error);
|
||||
|
||||
// Reset an existing account's password. Thread-safe.
|
||||
bool reset_password(const std::string& username, const std::string& new_password,
|
||||
std::string& error);
|
||||
|
||||
// Delete an account. Thread-safe.
|
||||
bool delete_account(const std::string& username, std::string& error);
|
||||
|
||||
// List all accounts (no passwords). Thread-safe.
|
||||
std::vector<Account> list_accounts();
|
||||
|
||||
// Verify username + password. Updates last_login on success.
|
||||
// Blocking (Argon2id) — must be called from a WorkerPool thread.
|
||||
std::optional<Account> authenticate(const std::string& username,
|
||||
const std::string& password);
|
||||
|
||||
// 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);
|
||||
int64_t now_unix() const;
|
||||
|
||||
std::string path_;
|
||||
sqlite3* db_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_SERVER_DB_H
|
||||
39
server/src/identity.cpp
Normal file
39
server/src/identity.cpp
Normal file
@@ -0,0 +1,39 @@
|
||||
#include "identity.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
bool ServerIdentityManager::init(const std::filesystem::path& data_dir,
|
||||
const std::string& server_name, std::string& error) {
|
||||
try {
|
||||
std::filesystem::create_directories(data_dir);
|
||||
|
||||
auto id_path = data_dir / "identity.key";
|
||||
auto cert_path = data_dir / "server.crt";
|
||||
auto key_path = data_dir / "server.key";
|
||||
|
||||
if (std::filesystem::exists(id_path) &&
|
||||
std::filesystem::exists(cert_path) &&
|
||||
std::filesystem::exists(key_path)) {
|
||||
identity_ = crypto::ServerIdentity::load(id_path);
|
||||
cert_ = crypto::ServerCert::load(cert_path, key_path);
|
||||
} else {
|
||||
identity_ = crypto::ServerIdentity::generate();
|
||||
cert_ = crypto::ServerCert::generate(server_name);
|
||||
identity_.save(id_path);
|
||||
cert_.save(cert_path, key_path);
|
||||
}
|
||||
return true;
|
||||
} catch (const std::exception& ex) {
|
||||
error = ex.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
40
server/src/identity.h
Normal file
40
server/src/identity.h
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* server/identity.h — Server identity manager.
|
||||
*
|
||||
* Loads or generates the Ed25519 identity key + self-signed TLS cert on first run.
|
||||
* Persists both to data_dir/identity.key and data_dir/server.{crt,key}.
|
||||
*/
|
||||
#ifndef VOICECAT_SERVER_IDENTITY_H
|
||||
#define VOICECAT_SERVER_IDENTITY_H
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
class ServerIdentityManager {
|
||||
public:
|
||||
// Load from data_dir, or generate on first run.
|
||||
// Returns false on fatal I/O error.
|
||||
bool init(const std::filesystem::path& data_dir, const std::string& server_name,
|
||||
std::string& error);
|
||||
|
||||
const crypto::ServerIdentity& identity() const { return identity_; }
|
||||
const crypto::ServerCert& cert() const { return cert_; }
|
||||
|
||||
// "AA:BB:CC:..." hex for display
|
||||
std::string fingerprint_display() const { return identity_.fingerprint_hex(); }
|
||||
|
||||
private:
|
||||
crypto::ServerIdentity identity_;
|
||||
crypto::ServerCert cert_;
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_SERVER_IDENTITY_H
|
||||
@@ -2,19 +2,170 @@
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#define ASIO_STANDALONE 1
|
||||
#include <asio.hpp>
|
||||
#include <asio/signal_set.hpp>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "conn_session.h"
|
||||
#include "core/worker_pool.h"
|
||||
#include "crypto/crypto.h"
|
||||
#include "db.h"
|
||||
#include "identity.h"
|
||||
#include "net/transport.h"
|
||||
#include "session_registry.h"
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
int Server::run() {
|
||||
// M0 stub: report what a real run WILL do, then exit. M1 brings up the TLS listener,
|
||||
// session registry, and channel manager (docs/architecture.md §5).
|
||||
std::printf(" server_name : %s\n", cfg_.server_name.c_str());
|
||||
std::printf(" data_dir : %s\n", cfg_.data_dir.c_str());
|
||||
std::printf(" bind_port : %u (TCP control + UDP media)\n", cfg_.bind_port);
|
||||
std::printf(" allow_guests: %s\n", cfg_.allow_guests ? "true" : "false");
|
||||
std::printf(" fingerprint : <generated on first run — TODO M1>\n");
|
||||
std::printf("\n[voicecat-server] M0 skeleton: networking not implemented yet. "
|
||||
"See docs/roadmap.md (M1) and AGENTS.md.\n");
|
||||
// ── Identity + cert ──────────────────────────────────────────────────────
|
||||
ServerIdentityManager id_mgr;
|
||||
std::string error;
|
||||
if (!id_mgr.init(cfg_.data_dir, cfg_.server_name, error)) {
|
||||
std::fprintf(stderr, "[server] identity init failed: %s\n", error.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Database + bootstrap admin ───────────────────────────────────────────
|
||||
auto db = std::make_shared<Database>(cfg_.data_dir + "/voicecat.db");
|
||||
if (!db->open(error)) {
|
||||
std::fprintf(stderr, "[server] db open failed: %s\n", error.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (db->is_empty()) {
|
||||
std::string pw = Database::generate_password(20);
|
||||
auto acc = db->create_account("admin", pw, true, error);
|
||||
if (!acc) {
|
||||
std::fprintf(stderr, "[server] failed to create admin account: %s\n", error.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("\n");
|
||||
std::printf("┌─────────────────────────────────────────────────────────┐\n");
|
||||
std::printf("│ First run — admin account created │\n");
|
||||
std::printf("│ username : %-44s│\n", "admin");
|
||||
std::printf("│ password : %-44s│\n", pw.c_str());
|
||||
std::printf("│ Change with: voicecat-admin account reset admin │\n");
|
||||
std::printf("└─────────────────────────────────────────────────────────┘\n");
|
||||
std::printf("\n");
|
||||
}
|
||||
|
||||
// ── Session registry ─────────────────────────────────────────────────────
|
||||
auto registry = std::make_shared<SessionRegistry>();
|
||||
registry->init_default_channels();
|
||||
|
||||
// ── Worker pool ──────────────────────────────────────────────────────────
|
||||
auto workers = std::make_shared<WorkerPool>(3);
|
||||
|
||||
// ── Asio io_context ──────────────────────────────────────────────────────
|
||||
asio::io_context io;
|
||||
|
||||
// Capture all locals by reference for the factory lambda (io lifetime is > factory)
|
||||
voicecat::net::TcpAcceptor acceptor(
|
||||
io, cfg_.bind_port,
|
||||
[&](asio::ip::tcp::socket sock) -> std::shared_ptr<voicecat::net::TcpServerConn> {
|
||||
auto session = std::make_shared<ConnSession>(
|
||||
db, registry, workers,
|
||||
id_mgr.identity().fingerprint,
|
||||
cfg_.allow_guests);
|
||||
|
||||
// Use shared_ptr (not weak_ptr) so TcpServerConn keeps ConnSession alive.
|
||||
// cycle is broken by weak_tcp in the send/close fns below.
|
||||
voicecat::net::TcpChannelCallbacks cbs;
|
||||
cbs.on_frame = [session](std::vector<uint8_t> f) {
|
||||
session->on_frame(std::move(f));
|
||||
};
|
||||
cbs.on_disconnected = [session] {
|
||||
session->on_disconnect();
|
||||
};
|
||||
cbs.on_error = [session](std::error_code) {
|
||||
session->on_disconnect();
|
||||
};
|
||||
|
||||
// Create a TLS context for this connection (server role).
|
||||
auto tls = std::make_unique<voicecat::crypto::TlsContext>(
|
||||
voicecat::crypto::TlsContext::Role::Server,
|
||||
&id_mgr.cert());
|
||||
|
||||
auto tcp = std::make_shared<voicecat::net::TcpServerConn>(
|
||||
std::move(sock), std::move(cbs), std::move(tls));
|
||||
|
||||
// Give session its send/close capability (weak_ptr avoids cycle)
|
||||
std::weak_ptr<voicecat::net::TcpServerConn> weak_tcp = tcp;
|
||||
session->set_io(
|
||||
[weak_tcp](std::vector<uint8_t> frame) {
|
||||
if (auto t = weak_tcp.lock()) t->send_frame(std::move(frame));
|
||||
},
|
||||
[weak_tcp] {
|
||||
if (auto t = weak_tcp.lock()) t->close();
|
||||
});
|
||||
|
||||
uint64_t sid = registry->register_session(session);
|
||||
session->set_session_id(sid);
|
||||
session->begin();
|
||||
// NOTE: do NOT call tcp->start() here — TcpAcceptor::do_accept() calls it.
|
||||
return tcp;
|
||||
});
|
||||
|
||||
acceptor.start();
|
||||
|
||||
// Arm programmatic stop (for tests and embedders).
|
||||
{
|
||||
std::lock_guard lk(stop_mutex_);
|
||||
stop_fn_ = [&io] { io.stop(); };
|
||||
}
|
||||
|
||||
// Notify caller of the actual bound port (matters when bind_port==0).
|
||||
uint16_t bound = acceptor.local_port();
|
||||
if (cfg_.on_ready) cfg_.on_ready(bound);
|
||||
|
||||
// Graceful shutdown on SIGINT/SIGTERM
|
||||
asio::signal_set signals(io, SIGINT, SIGTERM);
|
||||
signals.async_wait([&](std::error_code, int sig) {
|
||||
std::printf("\n[server] signal %d — shutting down\n", sig);
|
||||
acceptor.stop();
|
||||
io.stop();
|
||||
});
|
||||
|
||||
std::printf("[voicecat-server] %s — listening on :%u\n",
|
||||
cfg_.server_name.c_str(), bound);
|
||||
std::printf("[voicecat-server] fingerprint: %s\n",
|
||||
id_mgr.fingerprint_display().c_str());
|
||||
|
||||
io.run();
|
||||
|
||||
{
|
||||
std::lock_guard lk(stop_mutex_);
|
||||
stop_fn_ = nullptr;
|
||||
}
|
||||
|
||||
workers->join();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void Server::stop() {
|
||||
std::lock_guard lk(stop_mutex_);
|
||||
if (stop_fn_) stop_fn_();
|
||||
}
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#else // !VOICECAT_HAS_NET
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
int Server::run() {
|
||||
std::fprintf(stderr, "[server] stub: VOICECAT_HAS_NET not defined (build with m1-dev)\n");
|
||||
std::printf(" server_name : %s\n", cfg_.server_name.c_str());
|
||||
std::printf(" data_dir : %s\n", cfg_.data_dir.c_str());
|
||||
std::printf(" bind_port : %u\n", cfg_.bind_port);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void Server::stop() {}
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
/*
|
||||
* server.h — voicecat-server skeleton.
|
||||
* server/server.h — VoiceCat server entry point.
|
||||
*
|
||||
* Design: docs/architecture.md §5, docs/deployment.md. Headless process that links the core.
|
||||
* Responsibilities: connection manager (TLS), session registry, channel manager, text router,
|
||||
* voice SFU relay, SQLite persistence. Zero-config: self-provisions Ed25519 identity + cert
|
||||
* on first run, embedded SQLite, guests on by default.
|
||||
*
|
||||
* STATUS: M0 stub — prints config and exits; does not yet listen.
|
||||
* Design: docs/architecture.md §5. Headless process: TLS control listener,
|
||||
* session registry, text relay, SQLite persistence. Zero-config first-run.
|
||||
*/
|
||||
#ifndef VOICECAT_SERVER_SERVER_H
|
||||
#define VOICECAT_SERVER_SERVER_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
struct Config {
|
||||
std::string server_name = "VoiceCat Server";
|
||||
std::string data_dir = "voicecat-data";
|
||||
uint16_t bind_port = 8384; // TCP + UDP (docs/deployment.md §2)
|
||||
bool allow_guests = true;
|
||||
std::string data_dir = "voicecat-data";
|
||||
uint16_t bind_port = 8384; // 0 = let OS pick (useful for tests)
|
||||
bool allow_guests = true;
|
||||
// Called with the actual bound port once the acceptor is ready (m1-dev only).
|
||||
std::function<void(uint16_t)> on_ready;
|
||||
};
|
||||
|
||||
class Server {
|
||||
public:
|
||||
explicit Server(Config cfg) : cfg_(std::move(cfg)) {}
|
||||
|
||||
// TODO(M1): bind TLS control listener + UDP media socket; run the Asio loop until stop.
|
||||
// Returns process exit code.
|
||||
// Block until the server shuts down. Returns process exit code.
|
||||
int run();
|
||||
|
||||
// Thread-safe stop: unblocks run() from any thread. No-op if not running.
|
||||
void stop();
|
||||
|
||||
private:
|
||||
Config cfg_;
|
||||
Config cfg_;
|
||||
std::mutex stop_mutex_;
|
||||
std::function<void()> stop_fn_;
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
115
server/src/session_registry.cpp
Normal file
115
server/src/session_registry.cpp
Normal file
@@ -0,0 +1,115 @@
|
||||
#include "session_registry.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
|
||||
#include "conn_session.h"
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
void SessionRegistry::init_default_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);
|
||||
channels_[1] = std::move(lobby);
|
||||
}
|
||||
|
||||
uint64_t SessionRegistry::register_session(std::weak_ptr<ConnSession> session) {
|
||||
std::unique_lock lk(mu_);
|
||||
uint64_t id = next_session_id_++;
|
||||
sessions_[id] = std::move(session);
|
||||
return id;
|
||||
}
|
||||
|
||||
void SessionRegistry::unregister_session(uint64_t session_id) {
|
||||
std::unique_lock lk(mu_);
|
||||
sessions_.erase(session_id);
|
||||
}
|
||||
|
||||
uint32_t SessionRegistry::add_user(uint64_t session_id, const voicecat::v1::User& user) {
|
||||
std::unique_lock lk(mu_);
|
||||
uint32_t uid = next_user_id_++;
|
||||
UserEntry entry;
|
||||
entry.proto = user;
|
||||
entry.proto.set_id(uid);
|
||||
entry.proto.set_channel_id(1); // start in Lobby
|
||||
entry.session_id = session_id;
|
||||
users_[uid] = std::move(entry);
|
||||
return uid;
|
||||
}
|
||||
|
||||
void SessionRegistry::remove_user(uint32_t user_id) {
|
||||
std::unique_lock lk(mu_);
|
||||
users_.erase(user_id);
|
||||
}
|
||||
|
||||
bool SessionRegistry::set_user_channel(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);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<voicecat::v1::Channel> SessionRegistry::channel_snapshot() const {
|
||||
std::shared_lock lk(mu_);
|
||||
std::vector<voicecat::v1::Channel> result;
|
||||
result.reserve(channels_.size());
|
||||
for (auto& [id, entry] : channels_) result.push_back(entry.proto);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<voicecat::v1::User> SessionRegistry::user_snapshot() const {
|
||||
std::shared_lock lk(mu_);
|
||||
std::vector<voicecat::v1::User> result;
|
||||
result.reserve(users_.size());
|
||||
for (auto& [id, entry] : users_) result.push_back(entry.proto);
|
||||
return result;
|
||||
}
|
||||
|
||||
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_);
|
||||
std::vector<std::shared_ptr<ConnSession>> targets;
|
||||
|
||||
if (scope == voicecat::v1::TEXT_CHANNEL) {
|
||||
// Find channel_id of the target, then all users in that channel
|
||||
for (auto& [uid, entry] : users_) {
|
||||
if (entry.proto.channel_id() != target_id) continue;
|
||||
if (entry.session_id == sender_session_id) continue;
|
||||
auto sit = sessions_.find(entry.session_id);
|
||||
if (sit == sessions_.end()) continue;
|
||||
if (auto sess = sit->second.lock()) targets.push_back(sess);
|
||||
}
|
||||
} else if (scope == voicecat::v1::TEXT_PRIVATE) {
|
||||
// target_id is user_id
|
||||
auto user_it = users_.find(target_id);
|
||||
if (user_it != users_.end()) {
|
||||
auto sit = sessions_.find(user_it->second.session_id);
|
||||
if (sit != sessions_.end()) {
|
||||
if (auto sess = sit->second.lock()) targets.push_back(sess);
|
||||
}
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
void SessionRegistry::broadcast(const voicecat::v1::Envelope& env,
|
||||
uint64_t exclude_session_id) const {
|
||||
std::shared_lock lk(mu_);
|
||||
for (auto& [sid, weak] : sessions_) {
|
||||
if (sid == exclude_session_id) continue;
|
||||
if (auto sess = weak.lock()) sess->send_envelope(env);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
84
server/src/session_registry.h
Normal file
84
server/src/session_registry.h
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* server/session_registry.h — In-memory session, channel, and user registry.
|
||||
*
|
||||
* Tracks all authenticated sessions, the channel tree, and user<→>channel assignments.
|
||||
* Protected by a shared_mutex (many readers, few writers). All methods are thread-safe.
|
||||
*/
|
||||
#ifndef VOICECAT_SERVER_SESSION_REGISTRY_H
|
||||
#define VOICECAT_SERVER_SESSION_REGISTRY_H
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "proto/voicecat.pb.h"
|
||||
|
||||
namespace voicecat::server {
|
||||
|
||||
class ConnSession;
|
||||
|
||||
struct ChannelEntry {
|
||||
voicecat::v1::Channel proto;
|
||||
};
|
||||
|
||||
struct UserEntry {
|
||||
voicecat::v1::User proto;
|
||||
uint64_t session_id{};
|
||||
};
|
||||
|
||||
class SessionRegistry {
|
||||
public:
|
||||
SessionRegistry() = default;
|
||||
|
||||
// Create the default "Lobby" channel (id=1, permanent). Call once at startup.
|
||||
void init_default_channels();
|
||||
|
||||
// Register a session (before auth). Returns the assigned session_id.
|
||||
uint64_t register_session(std::weak_ptr<ConnSession> session);
|
||||
|
||||
// Remove a session (called on disconnect).
|
||||
void unregister_session(uint64_t session_id);
|
||||
|
||||
// Add a user once authenticated. Returns the assigned user_id.
|
||||
uint32_t add_user(uint64_t session_id, const voicecat::v1::User& user);
|
||||
|
||||
// Remove a user (called on disconnect after auth).
|
||||
void remove_user(uint32_t user_id);
|
||||
|
||||
// Move a user to a channel. Returns false if channel doesn't exist.
|
||||
bool set_user_channel(uint32_t user_id, uint32_t channel_id);
|
||||
|
||||
// Snapshot for ServerStateSnapshot message.
|
||||
std::vector<voicecat::v1::Channel> channel_snapshot() const;
|
||||
std::vector<voicecat::v1::User> user_snapshot() const;
|
||||
|
||||
// Resolve target sessions for a text message relay.
|
||||
// TEXT_CHANNEL: all users in that channel (except sender's session).
|
||||
// TEXT_PRIVATE: the session for that user_id.
|
||||
std::vector<std::shared_ptr<ConnSession>> resolve_text_targets(
|
||||
uint64_t sender_session_id, voicecat::v1::TextScope scope, uint32_t target_id) const;
|
||||
|
||||
// Broadcast an envelope to all sessions except the excluded one.
|
||||
void broadcast(const voicecat::v1::Envelope& env, uint64_t exclude_session_id = 0) const;
|
||||
|
||||
private:
|
||||
mutable std::shared_mutex mu_;
|
||||
|
||||
uint64_t next_session_id_{1};
|
||||
uint32_t next_user_id_{1};
|
||||
uint32_t next_channel_id_{2}; // 1 is reserved for Lobby
|
||||
|
||||
std::unordered_map<uint64_t, std::weak_ptr<ConnSession>> sessions_;
|
||||
std::unordered_map<uint32_t, UserEntry> users_;
|
||||
std::unordered_map<uint32_t, ChannelEntry> channels_;
|
||||
};
|
||||
|
||||
} // namespace voicecat::server
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_SERVER_SESSION_REGISTRY_H
|
||||
Reference in New Issue
Block a user