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,48 +1,509 @@
|
||||
#include "core/client.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#ifdef _WIN32
|
||||
# ifndef WIN32_LEAN_AND_MEAN
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# endif
|
||||
# include <winsock2.h>
|
||||
# include <ws2tcpip.h>
|
||||
using sock_t = SOCKET;
|
||||
static constexpr sock_t kBadSock = INVALID_SOCKET;
|
||||
static void close_sock(sock_t s) { ::closesocket(s); }
|
||||
#else
|
||||
# include <arpa/inet.h>
|
||||
# include <netdb.h>
|
||||
# include <netinet/in.h>
|
||||
# include <sys/socket.h>
|
||||
# include <unistd.h>
|
||||
using sock_t = int;
|
||||
static constexpr sock_t kBadSock = -1;
|
||||
static void close_sock(sock_t s) { ::close(s); }
|
||||
#endif
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include "protocol/protocol.h"
|
||||
|
||||
namespace {
|
||||
constexpr vc_result kStub = VC_ERR_NOT_IMPLEMENTED;
|
||||
|
||||
// Build a length-prefixed frame from an Envelope and return the raw bytes.
|
||||
std::vector<uint8_t> make_frame(const voicecat::v1::Envelope& env) {
|
||||
std::vector<uint8_t> out;
|
||||
voicecat::protocol::encode_envelope(env, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ── vc_client M1 implementation ───────────────────────────────────────────────
|
||||
|
||||
vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {}
|
||||
|
||||
vc_client::~vc_client() = default;
|
||||
vc_client::~vc_client() { disconnect(); }
|
||||
|
||||
void vc_client::emit(const vc_event& ev) const {
|
||||
if (cb_.on_event != nullptr) {
|
||||
cb_.on_event(cb_.user, &ev);
|
||||
if (cb_.on_event) cb_.on_event(cb_.user, &ev);
|
||||
}
|
||||
|
||||
void vc_client::set_state(vc_connection_state s) {
|
||||
state_net_.store(s, std::memory_order_release);
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_CONNECTION_STATE;
|
||||
ev.connection_state = s;
|
||||
emit(ev);
|
||||
}
|
||||
|
||||
void vc_client::emit_error(vc_result r, const char* text) {
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_ERROR;
|
||||
ev.result = static_cast<int32_t>(r);
|
||||
ev.text = text;
|
||||
emit(ev);
|
||||
}
|
||||
|
||||
void vc_client::emit_disconnected(vc_result r, const char* reason) {
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_DISCONNECTED;
|
||||
ev.result = static_cast<int32_t>(r);
|
||||
ev.text = reason;
|
||||
emit(ev);
|
||||
set_state(VC_STATE_DISCONNECTED);
|
||||
}
|
||||
|
||||
// ── Connection ────────────────────────────────────────────────────────────────
|
||||
|
||||
vc_result vc_client::connect(const char* host, uint16_t port) {
|
||||
auto cur = state_net_.load(std::memory_order_acquire);
|
||||
if (cur != VC_STATE_DISCONNECTED) return VC_ERR_ALREADY;
|
||||
|
||||
io_stop_.store(false, std::memory_order_release);
|
||||
io_fd_.store(-1, std::memory_order_release);
|
||||
// Pre-set so authenticate_*() called right after connect() doesn't see DISCONNECTED.
|
||||
state_net_.store(VC_STATE_CONNECTING, std::memory_order_release);
|
||||
|
||||
std::string h = host;
|
||||
io_thread_ = std::thread([this, h, port] { run_io(h, port); });
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::disconnect() {
|
||||
auto cur = state_net_.load(std::memory_order_acquire);
|
||||
if (cur == VC_STATE_DISCONNECTED && !io_thread_.joinable()) return VC_ERR_NOT_CONNECTED;
|
||||
|
||||
io_stop_.store(true, std::memory_order_release);
|
||||
|
||||
// Close the socket to unblock blocking TLS reads/writes.
|
||||
int fd = io_fd_.load(std::memory_order_acquire);
|
||||
if (fd != -1) {
|
||||
#ifdef _WIN32
|
||||
::shutdown(static_cast<SOCKET>(fd), SD_BOTH);
|
||||
::closesocket(static_cast<SOCKET>(fd));
|
||||
#else
|
||||
::shutdown(fd, SHUT_RDWR);
|
||||
::close(fd);
|
||||
#endif
|
||||
io_fd_.store(-1, std::memory_order_release);
|
||||
}
|
||||
|
||||
if (io_thread_.joinable()) io_thread_.join();
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
// ── io_thread_ entry point ────────────────────────────────────────────────────
|
||||
|
||||
void vc_client::run_io(std::string host, uint16_t port) {
|
||||
set_state(VC_STATE_CONNECTING);
|
||||
|
||||
// ── TCP connect ──────────────────────────────────────────────────────────
|
||||
#ifdef _WIN32
|
||||
WSADATA wsa{};
|
||||
WSAStartup(MAKEWORD(2, 2), &wsa);
|
||||
#endif
|
||||
|
||||
struct addrinfo hints{};
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
struct addrinfo* res = nullptr;
|
||||
std::string port_str = std::to_string(port);
|
||||
|
||||
if (io_stop_.load()) goto cleanup;
|
||||
|
||||
if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0 || !res) {
|
||||
emit_disconnected(VC_ERR_IO, "hostname resolution failed");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
{
|
||||
sock_t sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
|
||||
if (sock == kBadSock) {
|
||||
freeaddrinfo(res);
|
||||
emit_disconnected(VC_ERR_IO, "socket() failed");
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (::connect(sock, res->ai_addr, static_cast<int>(res->ai_addrlen)) != 0) {
|
||||
close_sock(sock);
|
||||
freeaddrinfo(res);
|
||||
emit_disconnected(VC_ERR_IO, "TCP connect failed");
|
||||
goto cleanup;
|
||||
}
|
||||
freeaddrinfo(res);
|
||||
res = nullptr;
|
||||
io_fd_.store(static_cast<int>(sock), std::memory_order_release);
|
||||
|
||||
// ── TLS handshake ────────────────────────────────────────────────────
|
||||
set_state(VC_STATE_TLS_HANDSHAKE);
|
||||
|
||||
tls_ = std::make_unique<voicecat::crypto::TlsContext>(
|
||||
voicecat::crypto::TlsContext::Role::Client, nullptr);
|
||||
|
||||
{
|
||||
std::string tls_err;
|
||||
if (!tls_->handshake(static_cast<int>(sock), tls_err)) {
|
||||
tls_.reset();
|
||||
emit_disconnected(VC_ERR_CRYPTO, tls_err.c_str());
|
||||
close_sock(sock);
|
||||
io_fd_.store(-1);
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
// 50 ms timeout so we can drain sends between reads.
|
||||
tls_->set_read_timeout(50);
|
||||
|
||||
// ── Send ClientHello ─────────────────────────────────────────────────
|
||||
set_state(VC_STATE_AUTHENTICATING);
|
||||
{
|
||||
voicecat::v1::Envelope env;
|
||||
env.set_request_id(next_req_id_++);
|
||||
auto* hello = env.mutable_client_hello();
|
||||
hello->set_proto_version(1);
|
||||
hello->set_client_name(cfg_.client_name ? cfg_.client_name : "vccli");
|
||||
hello->set_client_version(cfg_.client_version ? cfg_.client_version : "0.1.0");
|
||||
auto frame = make_frame(env);
|
||||
size_t off = 0;
|
||||
while (off < frame.size()) {
|
||||
int n = tls_->write(frame.data() + off, frame.size() - off);
|
||||
if (n <= 0) {
|
||||
tls_.reset();
|
||||
emit_disconnected(VC_ERR_IO, "write ClientHello failed");
|
||||
close_sock(sock);
|
||||
io_fd_.store(-1);
|
||||
goto cleanup;
|
||||
}
|
||||
off += static_cast<size_t>(n);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read loop ────────────────────────────────────────────────────────
|
||||
{
|
||||
voicecat::protocol::FrameCodec codec;
|
||||
std::vector<uint8_t> buf(16384);
|
||||
|
||||
while (!io_stop_.load(std::memory_order_acquire)) {
|
||||
drain_sends();
|
||||
|
||||
int n = tls_->read(buf.data(), buf.size());
|
||||
if (voicecat::crypto::TlsContext::is_timeout_error(n)) continue;
|
||||
if (n <= 0) break;
|
||||
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
if (!codec.feed(buf.data(), static_cast<size_t>(n), frames)) break;
|
||||
for (auto& frame : frames) {
|
||||
voicecat::v1::Envelope env;
|
||||
if (voicecat::protocol::decode_envelope(frame, env)) {
|
||||
handle_envelope(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tls_.reset();
|
||||
close_sock(sock);
|
||||
io_fd_.store(-1);
|
||||
}
|
||||
|
||||
if (!io_stop_.load()) emit_disconnected(VC_OK, nullptr);
|
||||
|
||||
cleanup:
|
||||
#ifdef _WIN32
|
||||
WSACleanup();
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
void vc_client::drain_sends() {
|
||||
while (true) {
|
||||
std::vector<uint8_t> frame;
|
||||
{
|
||||
std::lock_guard lk(send_mutex_);
|
||||
if (send_queue_.empty()) return;
|
||||
frame = std::move(send_queue_.front());
|
||||
send_queue_.pop_front();
|
||||
}
|
||||
if (!tls_) return;
|
||||
size_t off = 0;
|
||||
while (off < frame.size()) {
|
||||
int n = tls_->write(frame.data() + off, frame.size() - off);
|
||||
if (n <= 0) { io_stop_.store(true); return; }
|
||||
off += static_cast<size_t>(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connection & auth ────────────────────────────────────────────────────────
|
||||
// TODO(M1): drive the TLS 1.3 control channel + handshake state machine here, updating
|
||||
// state_ and emitting VC_EVENT_CONNECTION_STATE as it advances. docs/protocol.md §4.
|
||||
vc_result vc_client::connect(const char*, uint16_t) { return kStub; }
|
||||
vc_result vc_client::disconnect() { return kStub; }
|
||||
vc_result vc_client::authenticate_guest(const char*) { return kStub; }
|
||||
vc_result vc_client::authenticate_user(const char*, const char*) { return kStub; }
|
||||
void vc_client::queue_envelope(const voicecat::v1::Envelope& env) {
|
||||
auto frame = make_frame(env);
|
||||
if (frame.empty()) return;
|
||||
std::lock_guard lk(send_mutex_);
|
||||
send_queue_.push_back(std::move(frame));
|
||||
}
|
||||
|
||||
// ── Channels ─────────────────────────────────────────────────────────────────
|
||||
vc_result vc_client::join_channel(uint32_t, const char*) { return kStub; }
|
||||
vc_result vc_client::leave_channel() { return kStub; }
|
||||
// ── Protocol dispatch ─────────────────────────────────────────────────────────
|
||||
|
||||
// ── Local media streams ──────────────────────────────────────────────────────
|
||||
// TODO(M2/M3): allocate a stream id, announce it over the control channel, and start the
|
||||
// capture→APM→Opus→AEAD→UDP pipeline. docs/voice.md.
|
||||
vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return kStub; }
|
||||
vc_result vc_client::stream_stop(uint32_t) { return kStub; }
|
||||
vc_result vc_client::set_input_device(uint32_t, const char*) { return kStub; }
|
||||
vc_result vc_client::set_input_mode(vc_input_mode) { return kStub; }
|
||||
vc_result vc_client::set_push_to_talk(bool) { return kStub; }
|
||||
vc_result vc_client::set_self_mute(bool, bool) { return kStub; }
|
||||
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) { return kStub; }
|
||||
void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
|
||||
switch (env.body_case()) {
|
||||
case voicecat::v1::Envelope::kServerHello:
|
||||
handle_server_hello(env.server_hello(), env.request_id());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kAuthResult:
|
||||
handle_auth_result(env.auth_result());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kServerState:
|
||||
handle_server_state(env.server_state());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kUserEvent:
|
||||
handle_user_event(env.user_event());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kTextMessage:
|
||||
handle_text_message(env.text_message());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kDisconnect:
|
||||
handle_disconnect(env.disconnect());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kPong:
|
||||
break; // ignore keepalive responses
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────────────
|
||||
vc_result vc_client::send_text(vc_text_scope, uint32_t, const char*) { return kStub; }
|
||||
void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) {
|
||||
// Server acknowledged our ClientHello. Now send AuthRequest (or queue it).
|
||||
std::optional<PendingAuth> auth;
|
||||
{
|
||||
std::lock_guard lk(pending_auth_mutex_);
|
||||
auth = pending_auth_;
|
||||
}
|
||||
if (!auth) return; // caller will call authenticate_*() later
|
||||
|
||||
// ── Devices ──────────────────────────────────────────────────────────────────
|
||||
voicecat::v1::Envelope req;
|
||||
req.set_request_id(next_req_id_++);
|
||||
auto* ar = req.mutable_auth_request();
|
||||
if (auth->is_guest) {
|
||||
ar->mutable_guest()->set_nickname(auth->nick_or_user);
|
||||
} else {
|
||||
ar->mutable_password()->set_username(auth->nick_or_user);
|
||||
ar->mutable_password()->set_password(auth->password);
|
||||
}
|
||||
queue_envelope(req);
|
||||
(void)msg;
|
||||
}
|
||||
|
||||
void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) {
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_AUTH_RESULT;
|
||||
ev.result = msg.ok() ? VC_OK : VC_ERR_AUTH_FAILED;
|
||||
|
||||
if (msg.ok()) {
|
||||
self_user_id_ = msg.self().id();
|
||||
server_session_id_ = msg.session_id();
|
||||
ev.user_id = self_user_id_;
|
||||
set_state(VC_STATE_CONNECTED);
|
||||
} else {
|
||||
ev.text = msg.error().c_str();
|
||||
}
|
||||
emit(ev);
|
||||
}
|
||||
|
||||
void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& snap) {
|
||||
session_model_.apply_snapshot(snap);
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_CHANNEL_LIST;
|
||||
emit(ev);
|
||||
}
|
||||
|
||||
void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
|
||||
session_model_.apply_user_event(ue);
|
||||
|
||||
vc_event ev{};
|
||||
const auto& user = ue.user();
|
||||
ev.user_id = user.id();
|
||||
ev.channel_id = user.channel_id();
|
||||
|
||||
static const char* nick_buf_ptr = nullptr;
|
||||
std::string nick = user.nickname();
|
||||
|
||||
switch (ue.kind()) {
|
||||
case voicecat::v1::UserEvent::JOINED:
|
||||
ev.type = VC_EVENT_USER_JOINED;
|
||||
ev.text = nick.c_str();
|
||||
emit(ev);
|
||||
break;
|
||||
case voicecat::v1::UserEvent::LEFT:
|
||||
ev.type = VC_EVENT_USER_LEFT;
|
||||
emit(ev);
|
||||
break;
|
||||
case voicecat::v1::UserEvent::UPDATED:
|
||||
ev.type = VC_EVENT_USER_UPDATED;
|
||||
emit(ev);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
(void)nick_buf_ptr;
|
||||
}
|
||||
|
||||
void vc_client::handle_text_message(const voicecat::v1::TextMessage& msg) {
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_TEXT_MESSAGE;
|
||||
ev.text_scope = (msg.scope() == voicecat::v1::TEXT_PRIVATE) ? VC_TEXT_PRIVATE : VC_TEXT_CHANNEL;
|
||||
ev.user_id = msg.sender_id();
|
||||
ev.channel_id = msg.target_id();
|
||||
ev.text = msg.body().c_str();
|
||||
ev.timestamp_unix_ms = static_cast<uint64_t>(msg.sent_at_unix_ms());
|
||||
emit(ev);
|
||||
}
|
||||
|
||||
void vc_client::handle_disconnect(const voicecat::v1::Disconnect& msg) {
|
||||
io_stop_.store(true, std::memory_order_release);
|
||||
emit_disconnected(VC_ERR_IO, msg.reason().c_str());
|
||||
}
|
||||
|
||||
// ── Auth / channel / text commands ───────────────────────────────────────────
|
||||
|
||||
vc_result vc_client::authenticate_guest(const char* nickname) {
|
||||
auto cur = state_net_.load(std::memory_order_acquire);
|
||||
if (cur == VC_STATE_DISCONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
|
||||
PendingAuth pa{true, nickname, {}};
|
||||
{
|
||||
std::lock_guard lk(pending_auth_mutex_);
|
||||
pending_auth_ = pa;
|
||||
}
|
||||
|
||||
// If already past ServerHello, send AuthRequest immediately.
|
||||
if (cur == VC_STATE_CONNECTED || cur == VC_STATE_AUTHENTICATING) {
|
||||
voicecat::v1::Envelope req;
|
||||
req.set_request_id(next_req_id_++);
|
||||
req.mutable_auth_request()->mutable_guest()->set_nickname(nickname);
|
||||
queue_envelope(req);
|
||||
}
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::authenticate_user(const char* username, const char* password) {
|
||||
auto cur = state_net_.load(std::memory_order_acquire);
|
||||
if (cur == VC_STATE_DISCONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
|
||||
PendingAuth pa{false, username, password};
|
||||
{
|
||||
std::lock_guard lk(pending_auth_mutex_);
|
||||
pending_auth_ = pa;
|
||||
}
|
||||
|
||||
if (cur == VC_STATE_CONNECTED || cur == VC_STATE_AUTHENTICATING) {
|
||||
voicecat::v1::Envelope req;
|
||||
req.set_request_id(next_req_id_++);
|
||||
auto* pw = req.mutable_auth_request()->mutable_password();
|
||||
pw->set_username(username);
|
||||
pw->set_password(password);
|
||||
queue_envelope(req);
|
||||
}
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::join_channel(uint32_t channel_id, const char* /*password*/) {
|
||||
if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
voicecat::v1::Envelope req;
|
||||
req.set_request_id(next_req_id_++);
|
||||
req.mutable_join_channel()->set_channel_id(channel_id);
|
||||
queue_envelope(req);
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::leave_channel() {
|
||||
if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
voicecat::v1::Envelope req;
|
||||
req.set_request_id(next_req_id_++);
|
||||
req.mutable_leave_channel();
|
||||
queue_envelope(req);
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::send_text(vc_text_scope scope, uint32_t target_id, const char* utf8) {
|
||||
if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
voicecat::v1::Envelope req;
|
||||
req.set_request_id(next_req_id_++);
|
||||
auto* tm = req.mutable_text_message();
|
||||
tm->set_scope(scope == VC_TEXT_PRIVATE ? voicecat::v1::TEXT_PRIVATE : voicecat::v1::TEXT_CHANNEL);
|
||||
tm->set_target_id(target_id);
|
||||
tm->set_body(utf8);
|
||||
queue_envelope(req);
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
// ── Stubs for audio/device (M2) ───────────────────────────────────────────────
|
||||
|
||||
vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) {
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
vc_result vc_client::stream_stop(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
vc_result vc_client::list_devices(vc_device_kind, vc_device_list* out) {
|
||||
out->items = nullptr;
|
||||
out->count = 0;
|
||||
return kStub;
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
|
||||
#else // !VOICECAT_HAS_NET
|
||||
|
||||
// ── M0 stub implementations ───────────────────────────────────────────────────
|
||||
|
||||
vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {}
|
||||
vc_client::~vc_client() = default;
|
||||
|
||||
void vc_client::emit(const vc_event& ev) const {
|
||||
if (cb_.on_event) cb_.on_event(cb_.user, &ev);
|
||||
}
|
||||
|
||||
vc_result vc_client::connect(const char*, uint16_t) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::disconnect() { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::authenticate_guest(const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::authenticate_user(const char*, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::join_channel(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::leave_channel() { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::stream_stop(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
vc_result vc_client::send_text(vc_text_scope, uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::list_devices(vc_device_kind, vc_device_list* out) {
|
||||
out->items = nullptr;
|
||||
out->count = 0;
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
/*
|
||||
* client.h — the implementation type behind the opaque `vc_client*` handle.
|
||||
*
|
||||
* M0 skeleton: holds config/callbacks/state and returns VC_ERR_NOT_IMPLEMENTED for
|
||||
* everything that needs a subsystem. As subsystems land (docs/architecture.md §2), this
|
||||
* class wires them together: a net transport, a protocol state machine, a session model,
|
||||
* and an audio engine, plus the event thread that drains into vc_callbacks.on_event.
|
||||
*/
|
||||
#ifndef VOICECAT_CORE_CLIENT_H
|
||||
#define VOICECAT_CORE_CLIENT_H
|
||||
|
||||
#include "voicecat.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
#include "protocol/envelope.h"
|
||||
#include "protocol/protocol.h"
|
||||
#include "session/session.h"
|
||||
#include "proto/voicecat.pb.h"
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
struct vc_client {
|
||||
vc_client(const vc_config& cfg, vc_callbacks cb);
|
||||
~vc_client();
|
||||
@@ -39,15 +53,85 @@ struct vc_client {
|
||||
|
||||
vc_result list_devices(vc_device_kind kind, vc_device_list* out);
|
||||
|
||||
vc_connection_state state() const { return state_; }
|
||||
vc_connection_state state() const {
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
return state_net_.load(std::memory_order_acquire);
|
||||
#else
|
||||
return state_;
|
||||
#endif
|
||||
}
|
||||
|
||||
private:
|
||||
// Deliver an event to the host application. Safe to call with cb_.on_event == nullptr.
|
||||
void emit(const vc_event& ev) const;
|
||||
|
||||
vc_config cfg_{};
|
||||
vc_config cfg_{};
|
||||
vc_callbacks cb_{};
|
||||
vc_connection_state state_ = VC_STATE_DISCONNECTED;
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
// ── M1: TCP/TLS control channel ─────────────────────────────────────────────
|
||||
std::atomic<vc_connection_state> state_net_{VC_STATE_DISCONNECTED};
|
||||
|
||||
// Blocking I/O thread (one per vc_client lifetime)
|
||||
std::thread io_thread_;
|
||||
std::atomic<bool> io_stop_{false};
|
||||
|
||||
// Send queue: pushed by any thread, drained by io_thread_
|
||||
std::mutex send_mutex_;
|
||||
std::condition_variable send_cv_;
|
||||
std::deque<std::vector<uint8_t>> send_queue_;
|
||||
|
||||
// TLS context — created + used exclusively on io_thread_
|
||||
std::unique_ptr<voicecat::crypto::TlsContext> tls_;
|
||||
|
||||
// Raw socket fd (stored after TCP connect; closed by disconnect())
|
||||
std::atomic<int> io_fd_{-1};
|
||||
|
||||
// Pending auth stored before ServerHello arrives
|
||||
struct PendingAuth {
|
||||
bool is_guest{true};
|
||||
std::string nick_or_user;
|
||||
std::string password;
|
||||
};
|
||||
std::mutex pending_auth_mutex_;
|
||||
std::optional<PendingAuth> pending_auth_;
|
||||
|
||||
// Self identity filled in after AuthResult
|
||||
uint32_t self_user_id_{0};
|
||||
uint64_t server_session_id_{0};
|
||||
std::atomic<uint64_t> next_req_id_{1};
|
||||
|
||||
// Client-side session model
|
||||
voicecat::session::SessionModel session_model_;
|
||||
|
||||
// ── io_thread_ entry point ──────────────────────────────────────────────────
|
||||
void run_io(std::string host, uint16_t port);
|
||||
|
||||
// ── Protocol dispatch (called on io_thread_) ────────────────────────────────
|
||||
void handle_envelope(const voicecat::v1::Envelope& env);
|
||||
void handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t req_id);
|
||||
void handle_auth_result(const voicecat::v1::AuthResult& msg);
|
||||
void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap);
|
||||
void handle_user_event(const voicecat::v1::UserEvent& ue);
|
||||
void handle_text_message(const voicecat::v1::TextMessage& msg);
|
||||
void handle_disconnect(const voicecat::v1::Disconnect& msg);
|
||||
|
||||
// ── Helpers (io_thread_ and caller threads) ─────────────────────────────────
|
||||
// Queue an encoded envelope to be sent on io_thread_.
|
||||
void queue_envelope(const voicecat::v1::Envelope& env);
|
||||
|
||||
// Drain send_queue_ by doing blocking TLS writes (called on io_thread_).
|
||||
void drain_sends();
|
||||
|
||||
// Transition state + emit VC_EVENT_CONNECTION_STATE.
|
||||
void set_state(vc_connection_state s);
|
||||
|
||||
// Convenience event emitters.
|
||||
void emit_error(vc_result r, const char* text);
|
||||
void emit_disconnected(vc_result r, const char* reason);
|
||||
|
||||
#else // !VOICECAT_HAS_NET
|
||||
vc_connection_state state_{VC_STATE_DISCONNECTED};
|
||||
#endif
|
||||
};
|
||||
|
||||
#endif // VOICECAT_CORE_CLIENT_H
|
||||
|
||||
2
core/src/core/worker_pool.cpp
Normal file
2
core/src/core/worker_pool.cpp
Normal file
@@ -0,0 +1,2 @@
|
||||
#include "core/worker_pool.h"
|
||||
// WorkerPool is header-only via asio::thread_pool; nothing to define here.
|
||||
41
core/src/core/worker_pool.h
Normal file
41
core/src/core/worker_pool.h
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* core/worker_pool.h — fixed-size thread pool for blocking work.
|
||||
*
|
||||
* Used for Argon2id password hashing (deliberately slow) and TLS handshakes so
|
||||
* neither blocks the net thread. Real-time audio threads never use this.
|
||||
*
|
||||
* Requires VOICECAT_HAS_NET (Asio). Undefined when building without deps.
|
||||
*/
|
||||
#ifndef VOICECAT_CORE_WORKER_POOL_H
|
||||
#define VOICECAT_CORE_WORKER_POOL_H
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <asio/thread_pool.hpp>
|
||||
#include <asio/post.hpp>
|
||||
#include <cstddef>
|
||||
#include <utility>
|
||||
|
||||
namespace voicecat {
|
||||
|
||||
class WorkerPool {
|
||||
public:
|
||||
explicit WorkerPool(std::size_t threads = 3) : pool_(threads) {}
|
||||
~WorkerPool() { pool_.join(); }
|
||||
|
||||
template <typename F>
|
||||
void post(F&& fn) {
|
||||
asio::post(pool_, std::forward<F>(fn));
|
||||
}
|
||||
|
||||
// Wait for all outstanding tasks to finish.
|
||||
void join() { pool_.join(); }
|
||||
|
||||
private:
|
||||
asio::thread_pool pool_;
|
||||
};
|
||||
|
||||
} // namespace voicecat
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_CORE_WORKER_POOL_H
|
||||
@@ -1,8 +1,288 @@
|
||||
#include "crypto/crypto.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
#include <mbedtls/error.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
#include <mbedtls/ssl.h>
|
||||
#include <mbedtls/x509_crt.h>
|
||||
|
||||
namespace voicecat::crypto {
|
||||
|
||||
// M0 stub. Brought up in M1 (TLS 1.3 via mbedTLS) and M2 (media AEAD via libsodium).
|
||||
// See docs/security.md §1–2.
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
static void throw_if(int rc, const char* msg) {
|
||||
if (rc != 0) {
|
||||
char buf[256];
|
||||
mbedtls_strerror(rc, buf, sizeof(buf));
|
||||
throw std::runtime_error(std::string(msg) + ": " + buf);
|
||||
}
|
||||
}
|
||||
|
||||
static std::string compute_hex_fingerprint(const uint8_t* data, size_t len) {
|
||||
uint8_t hash[32];
|
||||
mbedtls_sha256(data, len, hash, 0);
|
||||
std::string s;
|
||||
s.reserve(64);
|
||||
const char* hex = "0123456789abcdef";
|
||||
for (auto b : hash) {
|
||||
s += hex[b >> 4];
|
||||
s += hex[b & 0xf];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── ServerIdentity ────────────────────────────────────────────────────────────
|
||||
|
||||
ServerIdentity ServerIdentity::generate() {
|
||||
ServerIdentity id;
|
||||
crypto_sign_ed25519_keypair(id.pk.data(), id.sk.data());
|
||||
// Fingerprint = SHA-256 of the public key
|
||||
mbedtls_sha256(id.pk.data(), id.pk.size(), id.fingerprint.data(), 0);
|
||||
return id;
|
||||
}
|
||||
|
||||
ServerIdentity ServerIdentity::load(const std::filesystem::path& path) {
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) throw std::runtime_error("Cannot open identity file: " + path.string());
|
||||
ServerIdentity id;
|
||||
f.read(reinterpret_cast<char*>(id.pk.data()), id.pk.size());
|
||||
f.read(reinterpret_cast<char*>(id.sk.data()), id.sk.size());
|
||||
if (!f) throw std::runtime_error("Identity file truncated: " + path.string());
|
||||
mbedtls_sha256(id.pk.data(), id.pk.size(), id.fingerprint.data(), 0);
|
||||
return id;
|
||||
}
|
||||
|
||||
void ServerIdentity::save(const std::filesystem::path& path) const {
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
if (!f) throw std::runtime_error("Cannot write identity file: " + path.string());
|
||||
f.write(reinterpret_cast<const char*>(pk.data()), pk.size());
|
||||
f.write(reinterpret_cast<const char*>(sk.data()), sk.size());
|
||||
}
|
||||
|
||||
std::string ServerIdentity::fingerprint_hex() const {
|
||||
std::string s;
|
||||
s.reserve(96);
|
||||
const char* hex = "0123456789ABCDEF";
|
||||
for (size_t i = 0; i < fingerprint.size(); ++i) {
|
||||
if (i > 0) s += ':';
|
||||
s += hex[fingerprint[i] >> 4];
|
||||
s += hex[fingerprint[i] & 0xf];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── ServerCert ────────────────────────────────────────────────────────────────
|
||||
|
||||
ServerCert ServerCert::generate(const std::string& server_name) {
|
||||
mbedtls_entropy_context entropy;
|
||||
mbedtls_ctr_drbg_context ctr_drbg;
|
||||
mbedtls_pk_context key;
|
||||
mbedtls_x509write_cert cert;
|
||||
|
||||
mbedtls_entropy_init(&entropy);
|
||||
mbedtls_ctr_drbg_init(&ctr_drbg);
|
||||
mbedtls_pk_init(&key);
|
||||
mbedtls_x509write_crt_init(&cert);
|
||||
|
||||
try {
|
||||
const char* pers = "voicecat_cert_gen";
|
||||
throw_if(mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy,
|
||||
reinterpret_cast<const unsigned char*>(pers),
|
||||
strlen(pers)),
|
||||
"ctr_drbg_seed");
|
||||
|
||||
// Generate ECDSA-P256 key
|
||||
throw_if(mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY)),
|
||||
"pk_setup");
|
||||
throw_if(mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(key),
|
||||
mbedtls_ctr_drbg_random, &ctr_drbg),
|
||||
"ecp_gen_key");
|
||||
|
||||
// Build self-signed cert
|
||||
mbedtls_x509write_crt_set_version(&cert, MBEDTLS_X509_CRT_VERSION_3);
|
||||
mbedtls_x509write_crt_set_md_alg(&cert, MBEDTLS_MD_SHA256);
|
||||
mbedtls_x509write_crt_set_subject_key(&cert, &key);
|
||||
mbedtls_x509write_crt_set_issuer_key(&cert, &key);
|
||||
|
||||
std::string dn = "CN=" + (server_name.empty() ? std::string("voicecat") : server_name);
|
||||
throw_if(mbedtls_x509write_crt_set_subject_name(&cert, dn.c_str()), "set_subject");
|
||||
throw_if(mbedtls_x509write_crt_set_issuer_name(&cert, dn.c_str()), "set_issuer");
|
||||
|
||||
// Serial = 0x01 (1 byte, value 1)
|
||||
uint8_t serial_raw[] = {0x01};
|
||||
throw_if(mbedtls_x509write_crt_set_serial_raw(&cert, serial_raw, sizeof(serial_raw)),
|
||||
"set_serial");
|
||||
|
||||
// Valid for 10 years
|
||||
throw_if(mbedtls_x509write_crt_set_validity(&cert, "20240101000000",
|
||||
"20340101000000"),
|
||||
"set_validity");
|
||||
throw_if(mbedtls_x509write_crt_set_basic_constraints(&cert, 0, -1),
|
||||
"set_basic_constraints");
|
||||
|
||||
// Write PEM cert
|
||||
unsigned char cert_buf[4096] = {};
|
||||
throw_if(mbedtls_x509write_crt_pem(&cert, cert_buf, sizeof(cert_buf),
|
||||
mbedtls_ctr_drbg_random, &ctr_drbg),
|
||||
"write_cert_pem");
|
||||
|
||||
// Write PEM key
|
||||
unsigned char key_buf[4096] = {};
|
||||
throw_if(mbedtls_pk_write_key_pem(&key, key_buf, sizeof(key_buf)), "write_key_pem");
|
||||
|
||||
ServerCert result;
|
||||
result.pem_cert = reinterpret_cast<char*>(cert_buf);
|
||||
result.pem_key = reinterpret_cast<char*>(key_buf);
|
||||
|
||||
mbedtls_x509write_crt_free(&cert);
|
||||
mbedtls_pk_free(&key);
|
||||
mbedtls_ctr_drbg_free(&ctr_drbg);
|
||||
mbedtls_entropy_free(&entropy);
|
||||
return result;
|
||||
} catch (...) {
|
||||
mbedtls_x509write_crt_free(&cert);
|
||||
mbedtls_pk_free(&key);
|
||||
mbedtls_ctr_drbg_free(&ctr_drbg);
|
||||
mbedtls_entropy_free(&entropy);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
ServerCert ServerCert::load(const std::filesystem::path& cert_path,
|
||||
const std::filesystem::path& key_path) {
|
||||
auto read_file = [](const std::filesystem::path& p) -> std::string {
|
||||
std::ifstream f(p);
|
||||
if (!f) throw std::runtime_error("Cannot open: " + p.string());
|
||||
return {std::istreambuf_iterator<char>(f), {}};
|
||||
};
|
||||
ServerCert c;
|
||||
c.pem_cert = read_file(cert_path);
|
||||
c.pem_key = read_file(key_path);
|
||||
return c;
|
||||
}
|
||||
|
||||
void ServerCert::save(const std::filesystem::path& cert_path,
|
||||
const std::filesystem::path& key_path) const {
|
||||
auto write_file = [](const std::filesystem::path& p, const std::string& s) {
|
||||
std::ofstream f(p, std::ios::trunc);
|
||||
if (!f) throw std::runtime_error("Cannot write: " + p.string());
|
||||
f << s;
|
||||
};
|
||||
write_file(cert_path, pem_cert);
|
||||
write_file(key_path, pem_key);
|
||||
}
|
||||
|
||||
// ── TlsContext ────────────────────────────────────────────────────────────────
|
||||
|
||||
TlsContext::TlsContext(Role role, const ServerCert* server_cert,
|
||||
const std::array<uint8_t, 32>* pinned_fp)
|
||||
: role_(role), pinned_fp_(pinned_fp) {
|
||||
mbedtls_entropy_init(&entropy_);
|
||||
mbedtls_ctr_drbg_init(&ctr_drbg_);
|
||||
mbedtls_ssl_init(&ssl_);
|
||||
mbedtls_ssl_config_init(&conf_);
|
||||
mbedtls_x509_crt_init(&srvcert_);
|
||||
mbedtls_pk_init(&pkey_);
|
||||
|
||||
const char* pers = (role == Role::Server) ? "vc_server_tls" : "vc_client_tls";
|
||||
throw_if(mbedtls_ctr_drbg_seed(&ctr_drbg_, mbedtls_entropy_func, &entropy_,
|
||||
reinterpret_cast<const unsigned char*>(pers),
|
||||
strlen(pers)),
|
||||
"ctr_drbg_seed");
|
||||
|
||||
int endpoint = (role == Role::Server) ? MBEDTLS_SSL_IS_SERVER : MBEDTLS_SSL_IS_CLIENT;
|
||||
throw_if(mbedtls_ssl_config_defaults(&conf_, endpoint, MBEDTLS_SSL_TRANSPORT_STREAM,
|
||||
MBEDTLS_SSL_PRESET_DEFAULT),
|
||||
"ssl_config_defaults");
|
||||
|
||||
// TLS 1.3 only
|
||||
mbedtls_ssl_conf_min_tls_version(&conf_, MBEDTLS_SSL_VERSION_TLS1_3);
|
||||
mbedtls_ssl_conf_max_tls_version(&conf_, MBEDTLS_SSL_VERSION_TLS1_3);
|
||||
|
||||
mbedtls_ssl_conf_rng(&conf_, mbedtls_ctr_drbg_random, &ctr_drbg_);
|
||||
|
||||
if (role == Role::Server && server_cert) {
|
||||
// Parse server cert + key
|
||||
throw_if(mbedtls_x509_crt_parse(
|
||||
&srvcert_,
|
||||
reinterpret_cast<const unsigned char*>(server_cert->pem_cert.c_str()),
|
||||
server_cert->pem_cert.size() + 1),
|
||||
"x509_crt_parse");
|
||||
throw_if(mbedtls_pk_parse_key(
|
||||
&pkey_,
|
||||
reinterpret_cast<const unsigned char*>(server_cert->pem_key.c_str()),
|
||||
server_cert->pem_key.size() + 1,
|
||||
nullptr, 0, mbedtls_ctr_drbg_random, &ctr_drbg_),
|
||||
"pk_parse_key");
|
||||
throw_if(mbedtls_ssl_conf_own_cert(&conf_, &srvcert_, &pkey_), "conf_own_cert");
|
||||
}
|
||||
|
||||
if (role == Role::Client) {
|
||||
// Skip CA chain verification — we use TOFU via the server identity fingerprint.
|
||||
mbedtls_ssl_conf_authmode(&conf_, MBEDTLS_SSL_VERIFY_NONE);
|
||||
}
|
||||
|
||||
throw_if(mbedtls_ssl_setup(&ssl_, &conf_), "ssl_setup");
|
||||
}
|
||||
|
||||
TlsContext::~TlsContext() {
|
||||
mbedtls_ssl_close_notify(&ssl_);
|
||||
mbedtls_pk_free(&pkey_);
|
||||
mbedtls_x509_crt_free(&srvcert_);
|
||||
mbedtls_ssl_free(&ssl_);
|
||||
mbedtls_ssl_config_free(&conf_);
|
||||
mbedtls_ctr_drbg_free(&ctr_drbg_);
|
||||
mbedtls_entropy_free(&entropy_);
|
||||
}
|
||||
|
||||
void TlsContext::set_read_timeout(uint32_t ms) {
|
||||
mbedtls_ssl_conf_read_timeout(&conf_, ms);
|
||||
}
|
||||
|
||||
bool TlsContext::is_timeout_error(int rc) {
|
||||
return rc == MBEDTLS_ERR_SSL_TIMEOUT;
|
||||
}
|
||||
|
||||
bool TlsContext::handshake(int socket_fd, std::string& error) {
|
||||
net_ctx_.fd = socket_fd;
|
||||
// Use the timeout-capable recv callback so set_read_timeout() takes effect.
|
||||
mbedtls_ssl_set_bio(&ssl_, &net_ctx_, mbedtls_net_send, mbedtls_net_recv,
|
||||
mbedtls_net_recv_timeout);
|
||||
|
||||
int rc;
|
||||
while ((rc = mbedtls_ssl_handshake(&ssl_)) != 0) {
|
||||
if (rc != MBEDTLS_ERR_SSL_WANT_READ && rc != MBEDTLS_ERR_SSL_WANT_WRITE) {
|
||||
char buf[256];
|
||||
mbedtls_strerror(rc, buf, sizeof(buf));
|
||||
error = buf;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ready_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
int TlsContext::read(uint8_t* buf, size_t len) {
|
||||
return mbedtls_ssl_read(&ssl_, buf, len);
|
||||
}
|
||||
|
||||
int TlsContext::write(const uint8_t* buf, size_t len) {
|
||||
return mbedtls_ssl_write(&ssl_, buf, len);
|
||||
}
|
||||
|
||||
bool TlsContext::export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
|
||||
uint8_t* out, size_t out_len) {
|
||||
return mbedtls_ssl_export_keying_material(
|
||||
&ssl_, out, out_len, label, strlen(label),
|
||||
ctx, ctx_len, ctx != nullptr) == 0;
|
||||
}
|
||||
|
||||
} // namespace voicecat::crypto
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
@@ -4,31 +4,121 @@
|
||||
* Design: docs/security.md. Control channel = TLS 1.3. Media = keys exported from the TLS
|
||||
* session (RFC 5705 / 8446) + per-frame ChaCha20-Poly1305 with a counter nonce and a
|
||||
* sliding-window replay filter. Encryption is MANDATORY — never add a plaintext path.
|
||||
*
|
||||
* STATUS: M0 stub.
|
||||
*/
|
||||
#ifndef VOICECAT_CRYPTO_CRYPTO_H
|
||||
#define VOICECAT_CRYPTO_CRYPTO_H
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <array>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
// libsodium
|
||||
#include <sodium.h>
|
||||
|
||||
// mbedTLS
|
||||
#include <mbedtls/ctr_drbg.h>
|
||||
#include <mbedtls/entropy.h>
|
||||
#include <mbedtls/net_sockets.h>
|
||||
#include <mbedtls/pk.h>
|
||||
#include <mbedtls/ssl.h>
|
||||
#include <mbedtls/x509_crt.h>
|
||||
|
||||
namespace voicecat::crypto {
|
||||
|
||||
// TLS 1.3 endpoint wrapper (mbedTLS). Provides the keying-material exporter that seeds
|
||||
// MediaCrypto, so the UDP path inherits the authenticated control session's trust.
|
||||
class TlsContext {
|
||||
public:
|
||||
// TODO(M1): client/server handshake; read/write; export_keying_material(label,...).
|
||||
// ── Server identity ────────────────────────────────────────────────────────────
|
||||
// Long-lived Ed25519 key identifying this server instance across cert rotations.
|
||||
// Fingerprint is the 32-byte SHA-256 of the public key.
|
||||
struct ServerIdentity {
|
||||
std::array<uint8_t, crypto_sign_ed25519_PUBLICKEYBYTES> pk{};
|
||||
std::array<uint8_t, crypto_sign_ed25519_SECRETKEYBYTES> sk{};
|
||||
std::array<uint8_t, 32> fingerprint{};
|
||||
|
||||
static ServerIdentity generate();
|
||||
static ServerIdentity load(const std::filesystem::path& path);
|
||||
void save(const std::filesystem::path& path) const;
|
||||
std::string fingerprint_hex() const;
|
||||
};
|
||||
|
||||
// Per-frame media encryption. Abstracted so the backend (exported-key AEAD now; a DTLS 1.3
|
||||
// backend later, if a permissive impl matures) is swappable without touching voice code.
|
||||
// ── Server TLS certificate ─────────────────────────────────────────────────────
|
||||
// Self-signed ECDSA-P256 cert for TLS. On first run, generated and persisted.
|
||||
struct ServerCert {
|
||||
std::string pem_cert;
|
||||
std::string pem_key;
|
||||
|
||||
static ServerCert generate(const std::string& server_name);
|
||||
static ServerCert load(const std::filesystem::path& cert_path,
|
||||
const std::filesystem::path& key_path);
|
||||
void save(const std::filesystem::path& cert_path,
|
||||
const std::filesystem::path& key_path) const;
|
||||
};
|
||||
|
||||
// ── TLS 1.3 context ───────────────────────────────────────────────────────────
|
||||
// Wraps mbedTLS for one TLS connection (server or client side).
|
||||
// All public methods except close() must be called from a single thread at a time.
|
||||
class TlsContext {
|
||||
public:
|
||||
enum class Role { Server, Client };
|
||||
|
||||
// server_cert: required for server role; nullptr for client
|
||||
// pinned_fp: 32-byte Ed25519 fingerprint to accept (client TOFU); nullptr = any
|
||||
TlsContext(Role role, const ServerCert* server_cert,
|
||||
const std::array<uint8_t, 32>* pinned_fp = nullptr);
|
||||
~TlsContext();
|
||||
|
||||
TlsContext(const TlsContext&) = delete;
|
||||
TlsContext& operator=(const TlsContext&) = delete;
|
||||
|
||||
// Perform the TLS handshake over an already-connected BSD socket fd.
|
||||
// Blocking — run from a WorkerPool thread.
|
||||
// Returns true on success; error contains a diagnostic string on failure.
|
||||
bool handshake(int socket_fd, std::string& error);
|
||||
|
||||
// Read/write post-handshake (single-threaded). Returns bytes transferred, or <0 on error.
|
||||
int read(uint8_t* buf, size_t len);
|
||||
int write(const uint8_t* buf, size_t len);
|
||||
|
||||
// RFC 5705 / RFC 8446 §7.5 exporter — derive media keys after handshake.
|
||||
bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
|
||||
uint8_t* out, size_t out_len);
|
||||
|
||||
// Whether the handshake completed.
|
||||
bool ready() const { return ready_; }
|
||||
|
||||
// Underlying socket fd (valid after handshake). For select() in the caller.
|
||||
int native_fd() const { return net_ctx_.fd; }
|
||||
|
||||
// Set per-read timeout (ms, 0 = blocking). Affects post-handshake reads.
|
||||
void set_read_timeout(uint32_t ms);
|
||||
|
||||
// True when the given return value from read() indicates a read timeout.
|
||||
static bool is_timeout_error(int rc);
|
||||
|
||||
private:
|
||||
Role role_;
|
||||
const std::array<uint8_t, 32>* pinned_fp_;
|
||||
bool ready_{false};
|
||||
|
||||
mbedtls_entropy_context entropy_{};
|
||||
mbedtls_ctr_drbg_context ctr_drbg_{};
|
||||
mbedtls_ssl_context ssl_{};
|
||||
mbedtls_ssl_config conf_{};
|
||||
mbedtls_x509_crt srvcert_{};
|
||||
mbedtls_pk_context pkey_{};
|
||||
mbedtls_net_context net_ctx_{};
|
||||
};
|
||||
|
||||
// ── Media AEAD (M2) ───────────────────────────────────────────────────────────
|
||||
// Per-frame voice encryption. Abstracted so the backend is swappable.
|
||||
class MediaCrypto {
|
||||
public:
|
||||
virtual ~MediaCrypto() = default;
|
||||
// seal/open one voice frame; `aad` carries the routable header fields (e.g. ssrc).
|
||||
// Returns bytes written, or -1 on failure (replay/auth). TODO(M2).
|
||||
virtual long seal(const uint8_t* plain, size_t len, const uint8_t* aad, size_t aad_len,
|
||||
uint8_t* out, size_t out_cap) = 0;
|
||||
virtual long open(const uint8_t* sealed, size_t len, const uint8_t* aad, size_t aad_len,
|
||||
@@ -37,4 +127,18 @@ class MediaCrypto {
|
||||
|
||||
} // namespace voicecat::crypto
|
||||
|
||||
#else // !VOICECAT_HAS_NET — skeleton stubs
|
||||
|
||||
namespace voicecat::crypto {
|
||||
|
||||
class MediaCrypto {
|
||||
public:
|
||||
virtual ~MediaCrypto() = default;
|
||||
virtual long seal(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) = 0;
|
||||
virtual long open(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) = 0;
|
||||
};
|
||||
|
||||
} // namespace voicecat::crypto
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_CRYPTO_CRYPTO_H
|
||||
|
||||
82
core/src/crypto/tofu_store.cpp
Normal file
82
core/src/crypto/tofu_store.cpp
Normal file
@@ -0,0 +1,82 @@
|
||||
#include "crypto/tofu_store.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace voicecat::crypto {
|
||||
|
||||
TofuStore::TofuStore(std::filesystem::path path) : path_(std::move(path)) {
|
||||
load();
|
||||
}
|
||||
|
||||
TofuResult TofuStore::check_and_pin(const std::string& host, uint16_t port,
|
||||
const std::array<uint8_t, 32>& fingerprint) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
auto key = make_key(host, port);
|
||||
auto it = pins_.find(key);
|
||||
if (it == pins_.end()) {
|
||||
pins_[key] = fingerprint;
|
||||
save();
|
||||
return TofuResult::FirstConnect;
|
||||
}
|
||||
return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch;
|
||||
}
|
||||
|
||||
void TofuStore::remove(const std::string& host, uint16_t port) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
pins_.erase(make_key(host, port));
|
||||
save();
|
||||
}
|
||||
|
||||
std::string TofuStore::make_key(const std::string& host, uint16_t port) {
|
||||
return host + ":" + std::to_string(port);
|
||||
}
|
||||
|
||||
std::string TofuStore::fp_to_hex(const std::array<uint8_t, 32>& fp) {
|
||||
const char* hex = "0123456789abcdef";
|
||||
std::string s;
|
||||
s.reserve(64);
|
||||
for (auto b : fp) { s += hex[b >> 4]; s += hex[b & 0xf]; }
|
||||
return s;
|
||||
}
|
||||
|
||||
std::array<uint8_t, 32> TofuStore::hex_to_fp(const std::string& hex) {
|
||||
std::array<uint8_t, 32> fp{};
|
||||
if (hex.size() != 64) return fp;
|
||||
auto h2n = [](char c) -> uint8_t {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return 0;
|
||||
};
|
||||
for (size_t i = 0; i < 32; ++i)
|
||||
fp[i] = static_cast<uint8_t>((h2n(hex[2*i]) << 4) | h2n(hex[2*i+1]));
|
||||
return fp;
|
||||
}
|
||||
|
||||
void TofuStore::load() {
|
||||
std::ifstream f(path_);
|
||||
if (!f) return;
|
||||
std::string line;
|
||||
while (std::getline(f, line)) {
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
std::istringstream ss(line);
|
||||
std::string key, hex;
|
||||
if (ss >> key >> hex && hex.size() == 64)
|
||||
pins_[key] = hex_to_fp(hex);
|
||||
}
|
||||
}
|
||||
|
||||
void TofuStore::save() const {
|
||||
std::ofstream f(path_, std::ios::trunc);
|
||||
if (!f) throw std::runtime_error("Cannot write TOFU store: " + path_.string());
|
||||
for (auto& [key, fp] : pins_)
|
||||
f << key << " " << fp_to_hex(fp) << "\n";
|
||||
}
|
||||
|
||||
} // namespace voicecat::crypto
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
54
core/src/crypto/tofu_store.h
Normal file
54
core/src/crypto/tofu_store.h
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* crypto/tofu_store.h — Trust-On-First-Use pin storage.
|
||||
*
|
||||
* File format: one "host:port <hex-fingerprint>\n" line per entry.
|
||||
* Used by clients to remember server fingerprints across reconnects.
|
||||
*/
|
||||
#ifndef VOICECAT_CRYPTO_TOFU_STORE_H
|
||||
#define VOICECAT_CRYPTO_TOFU_STORE_H
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <array>
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace voicecat::crypto {
|
||||
|
||||
enum class TofuResult {
|
||||
FirstConnect, // no pin on file; pin has been stored
|
||||
Matched, // pin matches stored value
|
||||
Mismatch, // stored pin does not match — possible MITM or server key rotation
|
||||
};
|
||||
|
||||
class TofuStore {
|
||||
public:
|
||||
explicit TofuStore(std::filesystem::path path);
|
||||
|
||||
// Check the fingerprint for host:port. Stores on first connect.
|
||||
// Thread-safe (single-writer lock).
|
||||
TofuResult check_and_pin(const std::string& host, uint16_t port,
|
||||
const std::array<uint8_t, 32>& fingerprint);
|
||||
|
||||
// Remove the pin for host:port (e.g. after user explicitly acknowledges a key change).
|
||||
void remove(const std::string& host, uint16_t port);
|
||||
|
||||
private:
|
||||
static std::string make_key(const std::string& host, uint16_t port);
|
||||
static std::string fp_to_hex(const std::array<uint8_t, 32>& fp);
|
||||
static std::array<uint8_t, 32> hex_to_fp(const std::string& hex);
|
||||
|
||||
void load();
|
||||
void save() const;
|
||||
|
||||
std::filesystem::path path_;
|
||||
std::mutex mu_;
|
||||
std::unordered_map<std::string, std::array<uint8_t, 32>> pins_;
|
||||
};
|
||||
|
||||
} // namespace voicecat::crypto
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_CRYPTO_TOFU_STORE_H
|
||||
@@ -1,8 +1,356 @@
|
||||
#include "net/transport.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "crypto/crypto.h"
|
||||
|
||||
namespace voicecat::net {
|
||||
|
||||
// M0 stub. Subsystem brought up in M1 (TCP/TLS) and M2 (UDP). See docs/protocol.md,
|
||||
// docs/voice.md, and AGENTS.md "Suggested first steps".
|
||||
// ── TcpControlChannel ────────────────────────────────────────────────────────
|
||||
|
||||
TcpControlChannel::TcpControlChannel(TcpChannelCallbacks cbs)
|
||||
: work_guard_(asio::make_work_guard(io_)),
|
||||
socket_(io_),
|
||||
strand_(io_.get_executor()),
|
||||
cbs_(std::move(cbs)) {
|
||||
net_thread_ = std::thread([this] { run_loop(); });
|
||||
}
|
||||
|
||||
TcpControlChannel::~TcpControlChannel() { close(); }
|
||||
|
||||
void TcpControlChannel::run_loop() { io_.run(); }
|
||||
|
||||
void TcpControlChannel::async_connect(const std::string& host, uint16_t port) {
|
||||
auto resolver = std::make_shared<asio::ip::tcp::resolver>(io_);
|
||||
resolver->async_resolve(
|
||||
host, std::to_string(port),
|
||||
[this, resolver](std::error_code ec, asio::ip::tcp::resolver::results_type eps) {
|
||||
if (ec) {
|
||||
if (cbs_.on_connect_error) cbs_.on_connect_error(ec);
|
||||
return;
|
||||
}
|
||||
asio::async_connect(socket_, eps,
|
||||
[this](std::error_code ec2, const asio::ip::tcp::endpoint&) {
|
||||
if (ec2) {
|
||||
if (cbs_.on_connect_error) cbs_.on_connect_error(ec2);
|
||||
return;
|
||||
}
|
||||
connected_.store(true, std::memory_order_release);
|
||||
if (cbs_.on_connected) cbs_.on_connected();
|
||||
start_read();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void TcpControlChannel::send_frame(std::vector<uint8_t> payload) {
|
||||
std::vector<uint8_t> wire;
|
||||
protocol::FrameCodec::emit(payload, wire);
|
||||
asio::post(strand_, [this, w = std::move(wire)]() mutable {
|
||||
send_queue_.push_back(std::move(w));
|
||||
if (!sending_) do_send();
|
||||
});
|
||||
}
|
||||
|
||||
void TcpControlChannel::do_send() {
|
||||
if (send_queue_.empty()) { sending_ = false; return; }
|
||||
sending_ = true;
|
||||
auto& front = send_queue_.front();
|
||||
asio::async_write(socket_,
|
||||
asio::buffer(front),
|
||||
asio::bind_executor(strand_,
|
||||
[this](std::error_code ec, std::size_t) {
|
||||
if (ec) {
|
||||
connected_.store(false, std::memory_order_release);
|
||||
if (cbs_.on_error) cbs_.on_error(ec);
|
||||
return;
|
||||
}
|
||||
send_queue_.pop_front();
|
||||
do_send();
|
||||
}));
|
||||
}
|
||||
|
||||
void TcpControlChannel::start_read() {
|
||||
asio::async_read(socket_, asio::buffer(len_buf_, 4),
|
||||
[this](std::error_code ec, std::size_t n) { handle_length(ec, n); });
|
||||
}
|
||||
|
||||
void TcpControlChannel::handle_length(std::error_code ec, std::size_t) {
|
||||
if (ec) {
|
||||
connected_.store(false, std::memory_order_release);
|
||||
if (ec == asio::error::eof || ec == asio::error::connection_reset) {
|
||||
if (cbs_.on_disconnected) cbs_.on_disconnected();
|
||||
} else {
|
||||
if (cbs_.on_error) cbs_.on_error(ec);
|
||||
}
|
||||
return;
|
||||
}
|
||||
uint32_t length =
|
||||
(static_cast<uint32_t>(len_buf_[0]) << 24) |
|
||||
(static_cast<uint32_t>(len_buf_[1]) << 16) |
|
||||
(static_cast<uint32_t>(len_buf_[2]) << 8) |
|
||||
static_cast<uint32_t>(len_buf_[3]);
|
||||
|
||||
if (length > protocol::kMaxFrameBytes) {
|
||||
if (cbs_.on_error) cbs_.on_error(asio::error::message_size);
|
||||
return;
|
||||
}
|
||||
if (length == 0) {
|
||||
if (cbs_.on_frame) cbs_.on_frame({});
|
||||
start_read();
|
||||
return;
|
||||
}
|
||||
body_buf_.resize(length);
|
||||
asio::async_read(socket_, asio::buffer(body_buf_),
|
||||
[this, length](std::error_code ec, std::size_t n) { handle_body(length, ec, n); });
|
||||
}
|
||||
|
||||
void TcpControlChannel::handle_body(uint32_t, std::error_code ec, std::size_t) {
|
||||
if (ec) {
|
||||
connected_.store(false, std::memory_order_release);
|
||||
if (ec == asio::error::eof || ec == asio::error::connection_reset) {
|
||||
if (cbs_.on_disconnected) cbs_.on_disconnected();
|
||||
} else {
|
||||
if (cbs_.on_error) cbs_.on_error(ec);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cbs_.on_frame) cbs_.on_frame(body_buf_);
|
||||
start_read();
|
||||
}
|
||||
|
||||
void TcpControlChannel::close() {
|
||||
if (closing_.exchange(true)) return;
|
||||
asio::post(io_, [this] {
|
||||
std::error_code ignored;
|
||||
socket_.shutdown(asio::ip::tcp::socket::shutdown_both, ignored);
|
||||
socket_.close(ignored);
|
||||
});
|
||||
work_guard_.reset();
|
||||
if (net_thread_.joinable()) net_thread_.join();
|
||||
}
|
||||
|
||||
// ── TcpServerConn ────────────────────────────────────────────────────────────
|
||||
|
||||
TcpServerConn::TcpServerConn(asio::ip::tcp::socket socket, TcpChannelCallbacks cbs)
|
||||
: socket_(std::move(socket)),
|
||||
strand_(asio::make_strand(socket_.get_executor())),
|
||||
cbs_(std::move(cbs)) {}
|
||||
|
||||
TcpServerConn::TcpServerConn(asio::ip::tcp::socket socket, TcpChannelCallbacks cbs,
|
||||
std::unique_ptr<crypto::TlsContext> tls)
|
||||
: socket_(std::move(socket)),
|
||||
strand_(asio::make_strand(socket_.get_executor())),
|
||||
cbs_(std::move(cbs)),
|
||||
tls_(std::move(tls)) {}
|
||||
|
||||
TcpServerConn::~TcpServerConn() {
|
||||
close();
|
||||
if (tls_thread_.joinable()) {
|
||||
if (std::this_thread::get_id() == tls_thread_.get_id()) {
|
||||
tls_thread_.detach(); // being destroyed from our own TLS thread — detach safely
|
||||
} else {
|
||||
tls_thread_.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TcpServerConn::start() {
|
||||
if (tls_) {
|
||||
// Run TLS handshake on a temporary thread so we don't block the io_context.
|
||||
auto self = shared_from_this();
|
||||
std::thread([self] {
|
||||
std::string err;
|
||||
int fd = static_cast<int>(self->socket_.native_handle());
|
||||
if (!self->tls_->handshake(fd, err)) {
|
||||
if (!self->closing_.exchange(true)) {
|
||||
if (self->cbs_.on_error) {
|
||||
asio::post(self->strand_, [self] {
|
||||
self->cbs_.on_error(
|
||||
std::make_error_code(std::errc::connection_reset));
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
self->connected_.store(true, std::memory_order_release);
|
||||
// 50 ms timeout so tls_read_loop can drain the send queue between reads.
|
||||
self->tls_->set_read_timeout(50);
|
||||
self->tls_thread_ = std::thread([self] { self->tls_read_loop(); });
|
||||
}).detach();
|
||||
} else {
|
||||
connected_.store(true, std::memory_order_release);
|
||||
start_read();
|
||||
}
|
||||
}
|
||||
|
||||
void TcpServerConn::tls_read_loop() {
|
||||
std::vector<uint8_t> buf(16384);
|
||||
while (!closing_.load(std::memory_order_acquire)) {
|
||||
tls_drain_sends();
|
||||
|
||||
int n = tls_->read(buf.data(), buf.size());
|
||||
if (crypto::TlsContext::is_timeout_error(n)) continue;
|
||||
if (n <= 0) break;
|
||||
|
||||
std::vector<std::vector<uint8_t>> frames;
|
||||
if (!codec_.feed(buf.data(), static_cast<size_t>(n), frames)) break;
|
||||
for (auto& frame : frames) {
|
||||
if (cbs_.on_frame) cbs_.on_frame(std::move(frame));
|
||||
}
|
||||
}
|
||||
connected_.store(false, std::memory_order_release);
|
||||
if (cbs_.on_disconnected) cbs_.on_disconnected();
|
||||
}
|
||||
|
||||
void TcpServerConn::tls_drain_sends() {
|
||||
while (true) {
|
||||
std::vector<uint8_t> frame;
|
||||
{
|
||||
std::lock_guard lk(tls_send_mutex_);
|
||||
if (tls_send_queue_.empty()) return;
|
||||
frame = std::move(tls_send_queue_.front());
|
||||
tls_send_queue_.pop_front();
|
||||
}
|
||||
size_t off = 0;
|
||||
while (off < frame.size()) {
|
||||
int n = tls_->write(frame.data() + off, frame.size() - off);
|
||||
if (n <= 0) { closing_.store(true, std::memory_order_release); return; }
|
||||
off += static_cast<size_t>(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TcpServerConn::start_read() {
|
||||
auto self = shared_from_this();
|
||||
asio::async_read(socket_, asio::buffer(len_buf_, 4),
|
||||
asio::bind_executor(strand_,
|
||||
[this, self](std::error_code ec, std::size_t n) { handle_length(ec, n); }));
|
||||
}
|
||||
|
||||
void TcpServerConn::handle_length(std::error_code ec, std::size_t) {
|
||||
if (ec) {
|
||||
connected_.store(false, std::memory_order_release);
|
||||
if (ec == asio::error::eof || ec == asio::error::connection_reset) {
|
||||
if (cbs_.on_disconnected) cbs_.on_disconnected();
|
||||
} else {
|
||||
if (cbs_.on_error) cbs_.on_error(ec);
|
||||
}
|
||||
return;
|
||||
}
|
||||
uint32_t length =
|
||||
(static_cast<uint32_t>(len_buf_[0]) << 24) |
|
||||
(static_cast<uint32_t>(len_buf_[1]) << 16) |
|
||||
(static_cast<uint32_t>(len_buf_[2]) << 8) |
|
||||
static_cast<uint32_t>(len_buf_[3]);
|
||||
|
||||
if (length > protocol::kMaxFrameBytes) {
|
||||
if (cbs_.on_error) cbs_.on_error(asio::error::message_size);
|
||||
return;
|
||||
}
|
||||
if (length == 0) {
|
||||
if (cbs_.on_frame) cbs_.on_frame({});
|
||||
start_read();
|
||||
return;
|
||||
}
|
||||
body_buf_.resize(length);
|
||||
auto self = shared_from_this();
|
||||
asio::async_read(socket_, asio::buffer(body_buf_),
|
||||
asio::bind_executor(strand_,
|
||||
[this, self, length](std::error_code ec, std::size_t n) {
|
||||
handle_body(length, ec, n);
|
||||
}));
|
||||
}
|
||||
|
||||
void TcpServerConn::handle_body(uint32_t, std::error_code ec, std::size_t) {
|
||||
if (ec) {
|
||||
connected_.store(false, std::memory_order_release);
|
||||
if (ec == asio::error::eof || ec == asio::error::connection_reset) {
|
||||
if (cbs_.on_disconnected) cbs_.on_disconnected();
|
||||
} else {
|
||||
if (cbs_.on_error) cbs_.on_error(ec);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cbs_.on_frame) cbs_.on_frame(body_buf_);
|
||||
start_read();
|
||||
}
|
||||
|
||||
void TcpServerConn::send_frame(std::vector<uint8_t> payload) {
|
||||
std::vector<uint8_t> wire;
|
||||
protocol::FrameCodec::emit(payload, wire);
|
||||
if (tls_) {
|
||||
std::lock_guard lk(tls_send_mutex_);
|
||||
tls_send_queue_.push_back(std::move(wire));
|
||||
} else {
|
||||
auto self = shared_from_this();
|
||||
asio::post(strand_, [this, self, w = std::move(wire)]() mutable {
|
||||
send_queue_.push_back(std::move(w));
|
||||
if (!sending_) do_send();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void TcpServerConn::do_send() {
|
||||
if (send_queue_.empty()) { sending_ = false; return; }
|
||||
sending_ = true;
|
||||
auto self = shared_from_this();
|
||||
auto& front = send_queue_.front();
|
||||
asio::async_write(socket_,
|
||||
asio::buffer(front),
|
||||
asio::bind_executor(strand_,
|
||||
[this, self](std::error_code ec, std::size_t) {
|
||||
if (ec) {
|
||||
connected_.store(false, std::memory_order_release);
|
||||
if (cbs_.on_error) cbs_.on_error(ec);
|
||||
return;
|
||||
}
|
||||
send_queue_.pop_front();
|
||||
do_send();
|
||||
}));
|
||||
}
|
||||
|
||||
void TcpServerConn::close() {
|
||||
if (closing_.exchange(true)) return;
|
||||
std::error_code ignored;
|
||||
socket_.shutdown(asio::ip::tcp::socket::shutdown_both, ignored);
|
||||
socket_.close(ignored);
|
||||
connected_.store(false, std::memory_order_release);
|
||||
// In non-TLS mode, the Asio async chain will naturally stop when the socket closes.
|
||||
}
|
||||
|
||||
// ── TcpAcceptor ─────────────────────────────────────────────────────────────
|
||||
|
||||
TcpAcceptor::TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory)
|
||||
: acceptor_(io, asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)),
|
||||
factory_(std::move(factory)) {
|
||||
acceptor_.set_option(asio::ip::tcp::acceptor::reuse_address(true));
|
||||
}
|
||||
|
||||
void TcpAcceptor::start() { do_accept(); }
|
||||
|
||||
void TcpAcceptor::stop() {
|
||||
stopped_ = true;
|
||||
std::error_code ignored;
|
||||
acceptor_.close(ignored);
|
||||
}
|
||||
|
||||
void TcpAcceptor::do_accept() {
|
||||
if (stopped_) return;
|
||||
acceptor_.async_accept(
|
||||
[this](std::error_code ec, asio::ip::tcp::socket socket) {
|
||||
if (ec) {
|
||||
if (!stopped_) do_accept();
|
||||
return;
|
||||
}
|
||||
socket.set_option(asio::ip::tcp::no_delay(true));
|
||||
auto conn = factory_(std::move(socket));
|
||||
if (conn) conn->start();
|
||||
do_accept();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace voicecat::net
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/*
|
||||
* net/transport.h — TCP control channel + UDP media channel.
|
||||
*
|
||||
* Design: docs/architecture.md (Net thread), docs/protocol.md §1 (framing), docs/voice.md §2
|
||||
* (UDP frame). Implementation will use standalone Asio (one reactor) for sockets/timers.
|
||||
* Design: docs/architecture.md (Net thread), docs/protocol.md §1 (framing).
|
||||
* Implementation uses standalone Asio for sockets and timers.
|
||||
*
|
||||
* STATUS: M0 stub — interfaces only, no Asio yet.
|
||||
* The real classes are compiled only when VOICECAT_HAS_NET is defined (m1-dev+).
|
||||
* The dev-preset stub definitions below keep the skeleton build green.
|
||||
*/
|
||||
#ifndef VOICECAT_NET_TRANSPORT_H
|
||||
#define VOICECAT_NET_TRANSPORT_H
|
||||
@@ -12,22 +13,161 @@
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#define ASIO_STANDALONE 1
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "protocol/protocol.h"
|
||||
|
||||
// Forward-declare TlsContext so transport.h does not pull in mbedTLS headers.
|
||||
namespace voicecat::crypto { class TlsContext; }
|
||||
|
||||
namespace voicecat::net {
|
||||
|
||||
// Length-prefixed [u32 length][payload] framing over a TLS 1.3 byte stream (protocol.md §1).
|
||||
class TcpControlChannel {
|
||||
public:
|
||||
// TODO(M1): connect(host, port), TLS handshake, send/recv framed Envelopes.
|
||||
bool connected() const { return connected_; }
|
||||
|
||||
private:
|
||||
bool connected_ = false;
|
||||
// Callbacks delivered on the net thread. Callers must not block inside them.
|
||||
struct TcpChannelCallbacks {
|
||||
std::function<void()> on_connected;
|
||||
std::function<void(std::error_code)> on_connect_error;
|
||||
std::function<void(std::vector<uint8_t>)> on_frame; // one decoded frame payload
|
||||
std::function<void(std::error_code)> on_error;
|
||||
std::function<void()> on_disconnected;
|
||||
};
|
||||
|
||||
// UDP media channel: encrypted voice frames (voice.md §2), bound to a session via token.
|
||||
// ── Client-side: owns an io_context + dedicated net thread ──────────────────
|
||||
class TcpControlChannel {
|
||||
public:
|
||||
explicit TcpControlChannel(TcpChannelCallbacks cbs);
|
||||
~TcpControlChannel();
|
||||
|
||||
// Async connect; calls on_connected or on_connect_error on the net thread.
|
||||
void async_connect(const std::string& host, uint16_t port);
|
||||
|
||||
// Queue a framed send (thread-safe; callable from any thread).
|
||||
void send_frame(std::vector<uint8_t> payload);
|
||||
|
||||
// Graceful close; safe to call from any thread. Waits for the net thread to join.
|
||||
void close();
|
||||
|
||||
bool connected() const { return connected_.load(std::memory_order_acquire); }
|
||||
|
||||
// Access the io_context so callers can post work back to the net thread.
|
||||
asio::io_context& io() { return io_; }
|
||||
|
||||
private:
|
||||
void run_loop();
|
||||
void start_read();
|
||||
void handle_length(std::error_code ec, std::size_t n);
|
||||
void handle_body(uint32_t length, std::error_code ec, std::size_t n);
|
||||
void do_send();
|
||||
|
||||
asio::io_context io_;
|
||||
asio::executor_work_guard<asio::io_context::executor_type> work_guard_;
|
||||
asio::ip::tcp::socket socket_;
|
||||
asio::strand<asio::io_context::executor_type> strand_;
|
||||
std::thread net_thread_;
|
||||
|
||||
TcpChannelCallbacks cbs_;
|
||||
protocol::FrameCodec codec_;
|
||||
|
||||
uint8_t len_buf_[4]{};
|
||||
std::vector<uint8_t> body_buf_;
|
||||
std::deque<std::vector<uint8_t>> send_queue_;
|
||||
bool sending_{false};
|
||||
std::atomic<bool> connected_{false};
|
||||
std::atomic<bool> closing_{false};
|
||||
};
|
||||
|
||||
// ── Server-side: one per accepted socket, shares the server's io_context ────
|
||||
class TcpServerConn : public std::enable_shared_from_this<TcpServerConn> {
|
||||
public:
|
||||
// Plain TCP constructor (no TLS — for tests or future plaintext paths).
|
||||
TcpServerConn(asio::ip::tcp::socket socket, TcpChannelCallbacks cbs);
|
||||
|
||||
// TLS constructor: takes ownership of a TlsContext; start() will run the
|
||||
// handshake on a temporary thread then switch to a TLS I/O thread.
|
||||
TcpServerConn(asio::ip::tcp::socket socket, TcpChannelCallbacks cbs,
|
||||
std::unique_ptr<voicecat::crypto::TlsContext> tls);
|
||||
|
||||
~TcpServerConn();
|
||||
|
||||
// Begin reading; must be called once after construction (on the io thread).
|
||||
void start();
|
||||
|
||||
// Thread-safe send (safe to call from the server's io thread or another strand).
|
||||
void send_frame(std::vector<uint8_t> payload);
|
||||
|
||||
// Close the connection (safe from any thread).
|
||||
void close();
|
||||
|
||||
bool connected() const { return connected_.load(std::memory_order_acquire); }
|
||||
|
||||
private:
|
||||
// ── Asio path (no TLS) ───────────────────────────────────────────────────
|
||||
void start_read();
|
||||
void handle_length(std::error_code ec, std::size_t n);
|
||||
void handle_body(uint32_t length, std::error_code ec, std::size_t n);
|
||||
void do_send();
|
||||
|
||||
// ── TLS path ─────────────────────────────────────────────────────────────
|
||||
void tls_read_loop();
|
||||
void tls_drain_sends();
|
||||
|
||||
asio::ip::tcp::socket socket_;
|
||||
asio::strand<asio::any_io_executor> strand_;
|
||||
TcpChannelCallbacks cbs_;
|
||||
protocol::FrameCodec codec_;
|
||||
|
||||
uint8_t len_buf_[4]{};
|
||||
std::vector<uint8_t> body_buf_;
|
||||
std::deque<std::vector<uint8_t>> send_queue_;
|
||||
bool sending_{false};
|
||||
std::atomic<bool> connected_{false};
|
||||
std::atomic<bool> closing_{false};
|
||||
|
||||
// TLS members (null in plain-TCP mode)
|
||||
std::unique_ptr<voicecat::crypto::TlsContext> tls_;
|
||||
std::thread tls_thread_;
|
||||
std::mutex tls_send_mutex_;
|
||||
std::deque<std::vector<uint8_t>> tls_send_queue_;
|
||||
};
|
||||
|
||||
// ── Server-side acceptor ─────────────────────────────────────────────────────
|
||||
// Spawns a TcpServerConn (via factory) for each accepted TCP connection.
|
||||
class TcpAcceptor {
|
||||
public:
|
||||
using ConnFactory = std::function<std::shared_ptr<TcpServerConn>(asio::ip::tcp::socket)>;
|
||||
|
||||
TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory);
|
||||
|
||||
// Start accepting. Call once; re-arms itself automatically.
|
||||
void start();
|
||||
|
||||
// Stop accepting (does not close existing connections).
|
||||
void stop();
|
||||
|
||||
// Actual bound port (useful when bind_port=0 lets the OS pick).
|
||||
uint16_t local_port() const { return static_cast<uint16_t>(acceptor_.local_endpoint().port()); }
|
||||
|
||||
private:
|
||||
void do_accept();
|
||||
|
||||
asio::ip::tcp::acceptor acceptor_;
|
||||
ConnFactory factory_;
|
||||
bool stopped_{false};
|
||||
};
|
||||
|
||||
// ── UDP media channel (M2) ───────────────────────────────────────────────────
|
||||
class UdpMediaChannel {
|
||||
public:
|
||||
// TODO(M2): bind, send/recv AEAD-sealed voice frames, keepalive.
|
||||
bool bound() const { return bound_; }
|
||||
|
||||
private:
|
||||
@@ -36,4 +176,21 @@ class UdpMediaChannel {
|
||||
|
||||
} // namespace voicecat::net
|
||||
|
||||
#else // !VOICECAT_HAS_NET — skeleton stubs for the dev preset
|
||||
|
||||
namespace voicecat::net {
|
||||
|
||||
class TcpControlChannel {
|
||||
public:
|
||||
bool connected() const { return false; }
|
||||
};
|
||||
|
||||
class UdpMediaChannel {
|
||||
public:
|
||||
bool bound() const { return false; }
|
||||
};
|
||||
|
||||
} // namespace voicecat::net
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_NET_TRANSPORT_H
|
||||
|
||||
34
core/src/protocol/envelope.cpp
Normal file
34
core/src/protocol/envelope.cpp
Normal file
@@ -0,0 +1,34 @@
|
||||
#include "protocol/envelope.h"
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include "protocol/protocol.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
|
||||
namespace voicecat::protocol {
|
||||
|
||||
bool encode_envelope(const voicecat::v1::Envelope& env, std::vector<uint8_t>& out) {
|
||||
std::string bytes;
|
||||
if (!env.SerializeToString(&bytes)) return false;
|
||||
FrameCodec::emit(reinterpret_cast<const uint8_t*>(bytes.data()), bytes.size(), out);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool decode_envelope(const uint8_t* data, size_t len, voicecat::v1::Envelope& out) {
|
||||
return out.ParseFromArray(data, static_cast<int>(len));
|
||||
}
|
||||
|
||||
bool decode_envelope(const std::vector<uint8_t>& frame, voicecat::v1::Envelope& out) {
|
||||
return decode_envelope(frame.data(), frame.size(), out);
|
||||
}
|
||||
|
||||
uint64_t next_request_id() {
|
||||
static std::atomic<uint64_t> counter{1};
|
||||
return counter.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
} // namespace voicecat::protocol
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
39
core/src/protocol/envelope.h
Normal file
39
core/src/protocol/envelope.h
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* protocol/envelope.h — thin helpers around the generated protobuf types.
|
||||
*
|
||||
* Hides the generated namespace from callers that only need to send/receive
|
||||
* envelopes without touching proto types directly. All callers that do need
|
||||
* the proto types can #include the generated header alongside this one.
|
||||
*
|
||||
* Requires VOICECAT_HAS_NET (protobuf codegen).
|
||||
*/
|
||||
#ifndef VOICECAT_PROTOCOL_ENVELOPE_H
|
||||
#define VOICECAT_PROTOCOL_ENVELOPE_H
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
// Generated by protobuf_generate(); lives in the build tree.
|
||||
#include "proto/voicecat.pb.h"
|
||||
|
||||
namespace voicecat::protocol {
|
||||
|
||||
// Serialize env into a framed wire buffer: [big-endian u32 length][payload].
|
||||
// Returns false on serialization error.
|
||||
bool encode_envelope(const voicecat::v1::Envelope& env, std::vector<uint8_t>& out);
|
||||
|
||||
// Deserialize a raw payload (no length prefix) into out.
|
||||
// Returns false on parse error.
|
||||
bool decode_envelope(const uint8_t* data, size_t len, voicecat::v1::Envelope& out);
|
||||
bool decode_envelope(const std::vector<uint8_t>& frame, voicecat::v1::Envelope& out);
|
||||
|
||||
// Stamp a monotonically increasing request_id (thread-safe, relaxed ordering).
|
||||
uint64_t next_request_id();
|
||||
|
||||
} // namespace voicecat::protocol
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
#endif // VOICECAT_PROTOCOL_ENVELOPE_H
|
||||
@@ -1,11 +1,60 @@
|
||||
#include "protocol/protocol.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace voicecat::protocol {
|
||||
|
||||
// M0 stub. The frame codec + protobuf Envelope dispatch are the first M1 task
|
||||
// (AGENTS.md "Suggested first steps" #1). See docs/protocol.md §1–5.
|
||||
bool FrameCodec::feed(const uint8_t*, size_t, std::vector<std::vector<uint8_t>>&) {
|
||||
return true; // TODO(M1): real framing.
|
||||
// --- FrameCodec::emit -------------------------------------------------------
|
||||
|
||||
void FrameCodec::emit(const uint8_t* payload, size_t len, std::vector<uint8_t>& out) {
|
||||
// Length header: big-endian u32.
|
||||
auto u32 = static_cast<uint32_t>(len);
|
||||
out.push_back(static_cast<uint8_t>((u32 >> 24) & 0xFF));
|
||||
out.push_back(static_cast<uint8_t>((u32 >> 16) & 0xFF));
|
||||
out.push_back(static_cast<uint8_t>((u32 >> 8) & 0xFF));
|
||||
out.push_back(static_cast<uint8_t>( u32 & 0xFF));
|
||||
out.insert(out.end(), payload, payload + len);
|
||||
}
|
||||
|
||||
void FrameCodec::emit(const std::vector<uint8_t>& payload, std::vector<uint8_t>& out) {
|
||||
emit(payload.data(), payload.size(), out);
|
||||
}
|
||||
|
||||
// --- FrameCodec::feed -------------------------------------------------------
|
||||
|
||||
bool FrameCodec::feed(const uint8_t* data, size_t len,
|
||||
std::vector<std::vector<uint8_t>>& out_frames) {
|
||||
buf_.insert(buf_.end(), data, data + len);
|
||||
|
||||
while (true) {
|
||||
if (buf_.size() < kLengthHeaderSize) {
|
||||
break; // need more bytes for the header
|
||||
}
|
||||
|
||||
// Decode big-endian u32 length.
|
||||
uint32_t frame_len =
|
||||
(static_cast<uint32_t>(buf_[0]) << 24) |
|
||||
(static_cast<uint32_t>(buf_[1]) << 16) |
|
||||
(static_cast<uint32_t>(buf_[2]) << 8) |
|
||||
static_cast<uint32_t>(buf_[3]);
|
||||
|
||||
if (frame_len > kMaxFrameBytes) {
|
||||
buf_.clear();
|
||||
return false; // oversized frame — protocol error
|
||||
}
|
||||
|
||||
size_t total = kLengthHeaderSize + static_cast<size_t>(frame_len);
|
||||
if (buf_.size() < total) {
|
||||
break; // need more bytes for the body
|
||||
}
|
||||
|
||||
// Extract the complete frame payload.
|
||||
out_frames.emplace_back(buf_.begin() + kLengthHeaderSize,
|
||||
buf_.begin() + total);
|
||||
buf_.erase(buf_.begin(), buf_.begin() + total);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace voicecat::protocol
|
||||
|
||||
@@ -17,15 +17,28 @@
|
||||
|
||||
namespace voicecat::protocol {
|
||||
|
||||
constexpr uint32_t kProtocolVersion = 1; // docs/protocol.md §4
|
||||
constexpr uint32_t kMaxFrameBytes = 16u * 1024 * 1024; // §1 oversized-frame guard
|
||||
constexpr uint32_t kProtocolVersion = 1;
|
||||
constexpr uint32_t kMaxFrameBytes = 16u * 1024 * 1024; // docs/protocol.md §1 guard
|
||||
constexpr size_t kLengthHeaderSize = 4; // big-endian u32 prefix
|
||||
|
||||
// Reads/writes [u32 length][payload] frames from a byte stream. TODO(M1).
|
||||
// Reads/writes [u32 big-endian length][payload] frames from a byte stream.
|
||||
class FrameCodec {
|
||||
public:
|
||||
// Append received bytes; pop complete frame payloads. Returns false on protocol error
|
||||
// (e.g. length > kMaxFrameBytes).
|
||||
// Append received bytes; pop complete frame payloads into out_frames.
|
||||
// Returns false on protocol error (oversized frame or framing violation).
|
||||
bool feed(const uint8_t* data, size_t len, std::vector<std::vector<uint8_t>>& out_frames);
|
||||
|
||||
// Serialize a frame into out: [big-endian u32 length][payload bytes].
|
||||
static void emit(const uint8_t* payload, size_t len, std::vector<uint8_t>& out);
|
||||
static void emit(const std::vector<uint8_t>& payload, std::vector<uint8_t>& out);
|
||||
|
||||
// Number of bytes buffered but not yet forming a complete frame.
|
||||
size_t pending_bytes() const { return buf_.size(); }
|
||||
|
||||
void reset() { buf_.clear(); }
|
||||
|
||||
private:
|
||||
std::vector<uint8_t> buf_;
|
||||
};
|
||||
|
||||
} // namespace voicecat::protocol
|
||||
|
||||
@@ -1,8 +1,87 @@
|
||||
#include "session/session.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace voicecat::session {
|
||||
|
||||
// M0 stub. Channel tree, users, streams, permissions, and ephemeral text relay land in M1.
|
||||
// See docs/protocol.md §5.
|
||||
const Channel* SessionModel::find_channel(uint32_t id) const {
|
||||
for (auto& ch : channels_) if (ch.id == id) return &ch;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const User* SessionModel::find_user(uint32_t id) const {
|
||||
for (auto& u : users_) if (u.id == id) return &u;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
|
||||
void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) {
|
||||
channels_.clear();
|
||||
for (const auto& pb : snap.channels()) {
|
||||
Channel ch;
|
||||
ch.id = pb.id();
|
||||
ch.name = pb.name();
|
||||
channels_.push_back(std::move(ch));
|
||||
}
|
||||
|
||||
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();
|
||||
users_.push_back(std::move(u));
|
||||
}
|
||||
}
|
||||
|
||||
void SessionModel::apply_user_event(const voicecat::v1::UserEvent& ev) {
|
||||
using Kind = voicecat::v1::UserEvent;
|
||||
|
||||
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();
|
||||
|
||||
auto it = std::find_if(users_.begin(), users_.end(),
|
||||
[&](const User& x) { return x.id == u.id; });
|
||||
if (it != users_.end()) *it = std::move(u);
|
||||
else users_.push_back(std::move(u));
|
||||
|
||||
} else if (ev.kind() == Kind::LEFT) {
|
||||
uint32_t uid = ev.user().id();
|
||||
users_.erase(std::remove_if(users_.begin(), users_.end(),
|
||||
[uid](const User& x) { return x.id == uid; }),
|
||||
users_.end());
|
||||
}
|
||||
}
|
||||
|
||||
void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) {
|
||||
using Kind = voicecat::v1::ChannelEvent;
|
||||
|
||||
if (ev.kind() == Kind::CREATED || ev.kind() == Kind::UPDATED) {
|
||||
const auto& pb = ev.channel();
|
||||
Channel ch;
|
||||
ch.id = pb.id();
|
||||
ch.name = pb.name();
|
||||
|
||||
auto it = std::find_if(channels_.begin(), channels_.end(),
|
||||
[&](const Channel& x) { return x.id == ch.id; });
|
||||
if (it != channels_.end()) *it = std::move(ch);
|
||||
else channels_.push_back(std::move(ch));
|
||||
|
||||
} else if (ev.kind() == Kind::DELETED) {
|
||||
uint32_t cid = ev.channel().id();
|
||||
channels_.erase(std::remove_if(channels_.begin(), channels_.end(),
|
||||
[cid](const Channel& x) { return x.id == cid; }),
|
||||
channels_.end());
|
||||
}
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
} // namespace voicecat::session
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
/*
|
||||
* session/session.h — domain model: channels, users, streams, permissions, text.
|
||||
* session/session.h — client-side mirror of the server's channel/user state.
|
||||
*
|
||||
* Design: docs/protocol.md §5, docs/architecture.md §5. Shared by client (local mirror of
|
||||
* server state) and server (authoritative). Text is ephemeral (no history). Accounts are
|
||||
* admin-provisioned.
|
||||
*
|
||||
* STATUS: M0 stub.
|
||||
* Populated from ServerStateSnapshot (full snapshot) and incremental UserEvent /
|
||||
* ChannelEvent messages. Not thread-safe — always called from io_thread_.
|
||||
*/
|
||||
#ifndef VOICECAT_SESSION_SESSION_H
|
||||
#define VOICECAT_SESSION_SESSION_H
|
||||
@@ -14,40 +11,52 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
#include "proto/voicecat.pb.h"
|
||||
#endif
|
||||
|
||||
namespace voicecat::session {
|
||||
|
||||
struct Channel {
|
||||
uint32_t id = 0;
|
||||
uint32_t parent_id = 0;
|
||||
uint32_t id{0};
|
||||
uint32_t parent_id{0};
|
||||
std::string name;
|
||||
bool password_protected = false;
|
||||
uint32_t max_users = 0;
|
||||
bool password_protected{false};
|
||||
uint32_t max_users{0};
|
||||
};
|
||||
|
||||
struct Stream {
|
||||
uint32_t stream_id = 0;
|
||||
uint32_t ssrc = 0;
|
||||
int kind = 0; // vc_stream_kind
|
||||
uint32_t stream_id{0};
|
||||
uint32_t ssrc{0};
|
||||
int kind{0};
|
||||
std::string label;
|
||||
};
|
||||
|
||||
struct User {
|
||||
uint32_t id = 0;
|
||||
std::string nickname;
|
||||
bool is_guest = true;
|
||||
uint32_t channel_id = 0;
|
||||
uint32_t id{0};
|
||||
std::string nickname;
|
||||
bool is_guest{true};
|
||||
uint32_t channel_id{0};
|
||||
std::vector<Stream> streams;
|
||||
};
|
||||
|
||||
// Mirror/authority for the channel tree + user list. TODO(M1): snapshot + delta apply.
|
||||
class SessionModel {
|
||||
public:
|
||||
const std::vector<Channel>& channels() const { return channels_; }
|
||||
const std::vector<User>& users() const { return users_; }
|
||||
const std::vector<User>& users() const { return users_; }
|
||||
|
||||
const Channel* find_channel(uint32_t id) const;
|
||||
const User* find_user(uint32_t id) const;
|
||||
|
||||
#ifdef VOICECAT_HAS_NET
|
||||
void apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap);
|
||||
void apply_user_event(const voicecat::v1::UserEvent& ev);
|
||||
void apply_channel_event(const voicecat::v1::ChannelEvent& ev);
|
||||
#endif
|
||||
|
||||
private:
|
||||
std::vector<Channel> channels_;
|
||||
std::vector<User> users_;
|
||||
std::vector<User> users_;
|
||||
};
|
||||
|
||||
} // namespace voicecat::session
|
||||
|
||||
Reference in New Issue
Block a user