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:
2026-06-15 23:48:44 +02:00
parent b332b0972b
commit 63f457fc54
42 changed files with 4180 additions and 190 deletions

View File

@@ -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