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:
@@ -4,9 +4,9 @@ Auto-loaded each session. This is the **map**: build commands, architecture at a
|
|||||||
where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done
|
where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done
|
||||||
and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/).
|
and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/).
|
||||||
|
|
||||||
> **One-line status:** M0 skeleton is complete and verified (builds + links + smoke test
|
> **One-line status:** M1 control plane is complete and verified (`ctest --preset m1-dev`
|
||||||
> passes). Next up is **M1** (TCP/TLS control plane, auth, channels, ephemeral text). See
|
> green — 5/5 tests including full TLS auth + text relay integration test). Next up is
|
||||||
> [`PROGRESS.md`](PROGRESS.md).
|
> **M2** (UDP media, Opus, jitter buffer). See [`PROGRESS.md`](PROGRESS.md).
|
||||||
|
|
||||||
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
|
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
|
||||||
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ endif()
|
|||||||
|
|
||||||
if(VOICECAT_BUILD_TOOLS)
|
if(VOICECAT_BUILD_TOOLS)
|
||||||
add_subdirectory(tools/vccli)
|
add_subdirectory(tools/vccli)
|
||||||
|
if(VOICECAT_USE_VCPKG_DEPS)
|
||||||
|
add_subdirectory(tools/voicecat-admin)
|
||||||
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(VOICECAT_BUILD_TESTS)
|
if(VOICECAT_BUILD_TESTS)
|
||||||
|
|||||||
@@ -21,6 +21,21 @@
|
|||||||
"toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
|
"toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
|
||||||
"cacheVariables": { "VOICECAT_USE_VCPKG_DEPS": "ON" }
|
"cacheVariables": { "VOICECAT_USE_VCPKG_DEPS": "ON" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "m1-dev",
|
||||||
|
"inherits": "vcpkg-base",
|
||||||
|
"displayName": "M1 Dev (TLS control plane, deps via vcpkg)",
|
||||||
|
"description": "Active development preset for M1+. Requires VCPKG_ROOT env var pointing to a bootstrapped vcpkg. Set VCPKG_ROOT=D:\\code\\nvgt\\vcpkg\\bin (or wherever your vcpkg is).",
|
||||||
|
"binaryDir": "${sourceDir}/build/m1-dev",
|
||||||
|
"cacheVariables": {
|
||||||
|
"CMAKE_BUILD_TYPE": "Debug",
|
||||||
|
"VOICECAT_USE_VCPKG_DEPS": "ON",
|
||||||
|
"VOICECAT_BUILD_TOOLS": "ON",
|
||||||
|
"VOICECAT_BUILD_TESTS": "ON",
|
||||||
|
"VCPKG_TARGET_TRIPLET": "x64-mingw-static",
|
||||||
|
"VCPKG_HOST_TRIPLET": "x64-mingw-static"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "server-release",
|
"name": "server-release",
|
||||||
"inherits": "vcpkg-base",
|
"inherits": "vcpkg-base",
|
||||||
@@ -28,15 +43,18 @@
|
|||||||
"binaryDir": "${sourceDir}/build/server-release",
|
"binaryDir": "${sourceDir}/build/server-release",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_BUILD_TYPE": "Release",
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
"VOICECAT_BUILD_TOOLS": "ON"
|
"VOICECAT_BUILD_TOOLS": "ON",
|
||||||
|
"VCPKG_TARGET_TRIPLET": "x64-mingw-static"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"buildPresets": [
|
"buildPresets": [
|
||||||
{ "name": "dev", "configurePreset": "dev" },
|
{ "name": "dev", "configurePreset": "dev" },
|
||||||
|
{ "name": "m1-dev", "configurePreset": "m1-dev" },
|
||||||
{ "name": "server-release", "configurePreset": "server-release" }
|
{ "name": "server-release", "configurePreset": "server-release" }
|
||||||
],
|
],
|
||||||
"testPresets": [
|
"testPresets": [
|
||||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } }
|
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
||||||
|
{ "name": "m1-dev", "configurePreset": "m1-dev", "output": { "outputOnFailure": true } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
57
PROGRESS.md
57
PROGRESS.md
@@ -10,19 +10,20 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## ▶ Where we left off / next action
|
## ▶ Where we left off / next action
|
||||||
|
|
||||||
- **Done:** design docs (`docs/`) + **M0 skeleton** — repo builds, links, and passes the
|
- **Done:** **M1 — control plane** ✓ complete (2026-06-15).
|
||||||
smoke test with no third-party deps.
|
`ctest --preset m1-dev` — all 5 tests green (smoke, frame_codec, envelope, tls_loopback,
|
||||||
- **Next:** start **M1 — control plane**. First concrete task: implement protobuf + the
|
m1_integration). Two clients authenticate over TLS 1.3 and exchange channel + private text.
|
||||||
`[u32 length][Envelope]` frame codec in `core/src/protocol/` and round-trip an `Envelope`
|
- **Next:** **M2 — voice, single stream**. First task: Opus encode/decode stub → real
|
||||||
in a test (see M1 checklist below and [`AGENTS.md`](AGENTS.md) "Suggested first steps").
|
implementation; UDP socket + ChaCha20-Poly1305 AEAD media frame; jitter buffer.
|
||||||
|
See `docs/voice.md` and `docs/roadmap.md §M2`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestones (see [docs/roadmap.md](docs/roadmap.md) for full detail)
|
## Milestones (see [docs/roadmap.md](docs/roadmap.md) for full detail)
|
||||||
|
|
||||||
- [x] **M0 — Scaffolding** ✓ complete
|
- [x] **M0 — Scaffolding** ✓ complete
|
||||||
- [~] **M1 — Control plane** (TCP/TLS, auth, channels, ephemeral text) ← current
|
- [x] **M1 — Control plane** ✓ complete (2026-06-15)
|
||||||
- [ ] **M2 — Voice, single stream** (UDP, Opus, jitter buffer, APM send-side, VAD/PTT)
|
- [~] **M2 — Voice, single stream** (UDP, Opus, jitter buffer, APM send-side, VAD/PTT) ← current
|
||||||
- [ ] **M3 — Multi-stream & per-channel tuning** (screen audio, listener-side per-user NR)
|
- [ ] **M3 — Multi-stream & per-channel tuning** (screen audio, listener-side per-user NR)
|
||||||
- [ ] **M4 — Native clients** (Windows C#, macOS/iOS Swift)
|
- [ ] **M4 — Native clients** (Windows C#, macOS/iOS Swift)
|
||||||
- [ ] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …)
|
- [ ] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …)
|
||||||
@@ -42,32 +43,28 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M1 — Control plane (current)
|
## M1 — Control plane ✓ (completed 2026-06-15)
|
||||||
|
|
||||||
**Exit criterion (definition of done):** two `vccli` instances connect to a real
|
**Exit criterion:** ✓ `test_m1_integration` — two clients authenticate over TLS 1.3 (guest
|
||||||
`voicecat-server` over **TLS 1.3**, authenticate (guest + admin-provisioned account), browse
|
+ Argon2id password), exchange channel and private text messages. Passes in ~1 s.
|
||||||
the channel tree, and exchange channel + private text messages. Encode this as an integration
|
|
||||||
test driving two clients.
|
|
||||||
|
|
||||||
Tasks (rough order — see [docs/protocol.md](docs/protocol.md), [docs/security.md](docs/security.md)):
|
- [x] vcpkg baseline + `m1-dev` preset; `find_package` for protobuf/mbedTLS/libsodium/asio/sqlite3.
|
||||||
|
- [x] `FrameCodec` feed + emit; `encode_envelope` / `decode_envelope`.
|
||||||
|
- [x] Asio TCP acceptor + `TcpServerConn` (TLS path: blocking handshake thread + `tls_read_loop`).
|
||||||
|
- [x] `TlsContext` (mbedTLS 1.3, server cert/identity, ECDSA-P256 self-signed, TOFU on client).
|
||||||
|
- [x] `WorkerPool` (3 threads, used for Argon2id).
|
||||||
|
- [x] `Database` — SQLite, Argon2id via libsodium, `create_account` / `authenticate` / bootstrap admin.
|
||||||
|
- [x] `voicecat-admin` — account add/reset/del/list against live DB file.
|
||||||
|
- [x] `ServerIdentityManager` — generate/persist Ed25519 key + cert; fingerprint display.
|
||||||
|
- [x] `ConnSession` — WaitingHello → WaitingAuth → Authenticated state machine; full protocol relay.
|
||||||
|
- [x] `SessionRegistry` — channel tree, user map, broadcast, text routing.
|
||||||
|
- [x] `vc_client` (`client.cpp`) — full M1 C ABI: connect/TLS/ClientHello/AuthRequest/text/disconnect.
|
||||||
|
- [x] `Server::run()` — io_context, acceptor, worker pool, signal handling, `on_ready` callback.
|
||||||
|
- [x] `test_m1_integration` — M1 exit criterion. Verified green 2026-06-15.
|
||||||
|
|
||||||
- [ ] Turn on vcpkg deps; set a real `builtin-baseline` in `vcpkg.json`; wire `find_package`
|
**Key bug fixed:** double-framing in `ConnSession::send_envelope` — `encode_envelope` was
|
||||||
for protobuf in `core/CMakeLists.txt` and `protobuf_generate` for `voicecat.proto`.
|
pre-framing the protobuf, then `TcpServerConn::send_frame` re-framed it. Fixed by serializing
|
||||||
- [ ] `protocol/`: implement the `[u32 length][Envelope]` `FrameCodec` (+ oversized-frame
|
raw protobuf bytes directly and letting `send_frame` add the single `[4-byte len]` prefix.
|
||||||
guard). **Test:** round-trip an `Envelope` through feed/emit.
|
|
||||||
- [ ] `net/`: plain TCP connect/accept via Asio; then wrap with **TLS 1.3 (mbedTLS)** in
|
|
||||||
`crypto/`. **Test:** `vccli` ↔ `voicecat-server` complete a TLS handshake.
|
|
||||||
- [ ] Handshake: `ClientHello`/`ServerHello` with version + feature negotiation.
|
|
||||||
- [ ] Server identity: generate/persist Ed25519 key + self-signed cert on first run; expose
|
|
||||||
fingerprint; client TOFU pin. (docs/security.md §1)
|
|
||||||
- [ ] Auth: `AuthRequest` → `AuthResult`; guest path + Argon2id password verify (libsodium);
|
|
||||||
SQLite accounts; `voicecat-admin` account add/reset/del/list. (docs/security.md §4)
|
|
||||||
- [ ] Session model: channel tree snapshot (`ServerStateSnapshot`) + `ChannelEvent`/`UserEvent`
|
|
||||||
deltas; join/leave; create/edit/delete (permission-gated).
|
|
||||||
- [ ] Text: ephemeral relay of channel + private messages with acks (no history). (protocol.md §5)
|
|
||||||
- [ ] Wire the C ABI: `vc_connect/authenticate_*/join_channel/send_text` drive the above and
|
|
||||||
emit `vc_event`s; `vccli` exercises them.
|
|
||||||
- [ ] **Integration test:** two `vccli` chat through the server over TLS. ← M1 exit.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,19 +25,35 @@ set_target_properties(voicecat PROPERTIES
|
|||||||
VISIBILITY_INLINES_HIDDEN ON)
|
VISIBILITY_INLINES_HIDDEN ON)
|
||||||
|
|
||||||
if(VOICECAT_USE_VCPKG_DEPS)
|
if(VOICECAT_USE_VCPKG_DEPS)
|
||||||
# Wire real dependencies here as each subsystem is implemented. Example (uncomment
|
find_package(protobuf CONFIG REQUIRED)
|
||||||
# the ones a subsystem needs; see docs/tech-stack.md and core/proto for protobuf):
|
find_package(unofficial-sodium CONFIG REQUIRED)
|
||||||
# find_package(unofficial-sodium CONFIG REQUIRED) # crypto/ (libsodium)
|
find_package(MbedTLS CONFIG REQUIRED)
|
||||||
# find_package(MbedTLS CONFIG REQUIRED) # crypto/ (TLS 1.3)
|
find_package(asio CONFIG REQUIRED)
|
||||||
# find_package(Opus CONFIG REQUIRED) # codec/
|
find_package(unofficial-sqlite3 CONFIG REQUIRED)
|
||||||
# find_package(protobuf CONFIG REQUIRED) # protocol/
|
find_package(spdlog CONFIG REQUIRED)
|
||||||
# find_package(asio CONFIG REQUIRED) # net/
|
|
||||||
# find_package(unofficial-sqlite3 CONFIG REQUIRED) # server persistence
|
# Generate C++ from voicecat.proto into the build tree.
|
||||||
# find_package(spdlog CONFIG REQUIRED)
|
protobuf_generate(
|
||||||
# target_link_libraries(voicecat PRIVATE Opus::opus protobuf::libprotobuf ...)
|
TARGET voicecat
|
||||||
#
|
PROTOS proto/voicecat.proto
|
||||||
# protobuf codegen (when protocol/ is implemented):
|
LANGUAGE cpp
|
||||||
# find_package(Protobuf CONFIG REQUIRED)
|
IMPORT_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/proto
|
||||||
# protobuf_generate(TARGET voicecat PROTOS proto/voicecat.proto LANGUAGE cpp)
|
PROTOC_OUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated/proto)
|
||||||
message(STATUS "voicecat: deps ON — add find_package()/link calls here as subsystems land")
|
# Generated .pb.h files are included by protocol/envelope.h (consumed by tests and server),
|
||||||
|
# so the generated dir and protobuf itself must be PUBLIC.
|
||||||
|
target_include_directories(voicecat PUBLIC ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||||
|
|
||||||
|
target_link_libraries(voicecat
|
||||||
|
PUBLIC protobuf::libprotobuf
|
||||||
|
PRIVATE unofficial-sodium::sodium
|
||||||
|
MbedTLS::mbedtls MbedTLS::mbedcrypto MbedTLS::mbedx509
|
||||||
|
asio::asio unofficial::sqlite3::sqlite3 spdlog::spdlog)
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
# AcceptEx / GetAcceptExSockaddrs live in mswsock; ws2_32 covers the base Winsock API.
|
||||||
|
target_link_libraries(voicecat PRIVATE ws2_32 mswsock)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Signal to C++ code that the real networking/crypto stack is available.
|
||||||
|
target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_NET)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -1,48 +1,509 @@
|
|||||||
#include "core/client.h"
|
#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 {
|
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
|
} // namespace
|
||||||
|
|
||||||
|
// ── vc_client M1 implementation ───────────────────────────────────────────────
|
||||||
|
|
||||||
vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {}
|
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 {
|
void vc_client::emit(const vc_event& ev) const {
|
||||||
if (cb_.on_event != nullptr) {
|
if (cb_.on_event) cb_.on_event(cb_.user, &ev);
|
||||||
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 ────────────────────────────────────────────────────────
|
void vc_client::queue_envelope(const voicecat::v1::Envelope& env) {
|
||||||
// TODO(M1): drive the TLS 1.3 control channel + handshake state machine here, updating
|
auto frame = make_frame(env);
|
||||||
// state_ and emitting VC_EVENT_CONNECTION_STATE as it advances. docs/protocol.md §4.
|
if (frame.empty()) return;
|
||||||
vc_result vc_client::connect(const char*, uint16_t) { return kStub; }
|
std::lock_guard lk(send_mutex_);
|
||||||
vc_result vc_client::disconnect() { return kStub; }
|
send_queue_.push_back(std::move(frame));
|
||||||
vc_result vc_client::authenticate_guest(const char*) { return kStub; }
|
}
|
||||||
vc_result vc_client::authenticate_user(const char*, const char*) { return kStub; }
|
|
||||||
|
|
||||||
// ── Channels ─────────────────────────────────────────────────────────────────
|
// ── Protocol dispatch ─────────────────────────────────────────────────────────
|
||||||
vc_result vc_client::join_channel(uint32_t, const char*) { return kStub; }
|
|
||||||
vc_result vc_client::leave_channel() { return kStub; }
|
|
||||||
|
|
||||||
// ── Local media streams ──────────────────────────────────────────────────────
|
void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
|
||||||
// TODO(M2/M3): allocate a stream id, announce it over the control channel, and start the
|
switch (env.body_case()) {
|
||||||
// capture→APM→Opus→AEAD→UDP pipeline. docs/voice.md.
|
case voicecat::v1::Envelope::kServerHello:
|
||||||
vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return kStub; }
|
handle_server_hello(env.server_hello(), env.request_id());
|
||||||
vc_result vc_client::stream_stop(uint32_t) { return kStub; }
|
break;
|
||||||
vc_result vc_client::set_input_device(uint32_t, const char*) { return kStub; }
|
case voicecat::v1::Envelope::kAuthResult:
|
||||||
vc_result vc_client::set_input_mode(vc_input_mode) { return kStub; }
|
handle_auth_result(env.auth_result());
|
||||||
vc_result vc_client::set_push_to_talk(bool) { return kStub; }
|
break;
|
||||||
vc_result vc_client::set_self_mute(bool, bool) { return kStub; }
|
case voicecat::v1::Envelope::kServerState:
|
||||||
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) { return kStub; }
|
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 ─────────────────────────────────────────────────────────────────────
|
void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) {
|
||||||
vc_result vc_client::send_text(vc_text_scope, uint32_t, const char*) { return kStub; }
|
// 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) {
|
vc_result vc_client::list_devices(vc_device_kind, vc_device_list* out) {
|
||||||
out->items = nullptr;
|
out->items = nullptr;
|
||||||
out->count = 0;
|
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.
|
* 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
|
#ifndef VOICECAT_CORE_CLIENT_H
|
||||||
#define VOICECAT_CORE_CLIENT_H
|
#define VOICECAT_CORE_CLIENT_H
|
||||||
|
|
||||||
#include "voicecat.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 {
|
struct vc_client {
|
||||||
vc_client(const vc_config& cfg, vc_callbacks cb);
|
vc_client(const vc_config& cfg, vc_callbacks cb);
|
||||||
~vc_client();
|
~vc_client();
|
||||||
@@ -39,15 +53,85 @@ struct vc_client {
|
|||||||
|
|
||||||
vc_result list_devices(vc_device_kind kind, vc_device_list* out);
|
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:
|
private:
|
||||||
// Deliver an event to the host application. Safe to call with cb_.on_event == nullptr.
|
|
||||||
void emit(const vc_event& ev) const;
|
void emit(const vc_event& ev) const;
|
||||||
|
|
||||||
vc_config cfg_{};
|
vc_config cfg_{};
|
||||||
vc_callbacks cb_{};
|
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
|
#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"
|
#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 {
|
namespace voicecat::crypto {
|
||||||
|
|
||||||
// M0 stub. Brought up in M1 (TLS 1.3 via mbedTLS) and M2 (media AEAD via libsodium).
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
// See docs/security.md §1–2.
|
|
||||||
|
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
|
} // 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
|
* 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
|
* 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.
|
* sliding-window replay filter. Encryption is MANDATORY — never add a plaintext path.
|
||||||
*
|
|
||||||
* STATUS: M0 stub.
|
|
||||||
*/
|
*/
|
||||||
#ifndef VOICECAT_CRYPTO_CRYPTO_H
|
#ifndef VOICECAT_CRYPTO_CRYPTO_H
|
||||||
#define VOICECAT_CRYPTO_CRYPTO_H
|
#define VOICECAT_CRYPTO_CRYPTO_H
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#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 {
|
namespace voicecat::crypto {
|
||||||
|
|
||||||
// TLS 1.3 endpoint wrapper (mbedTLS). Provides the keying-material exporter that seeds
|
// ── Server identity ────────────────────────────────────────────────────────────
|
||||||
// MediaCrypto, so the UDP path inherits the authenticated control session's trust.
|
// Long-lived Ed25519 key identifying this server instance across cert rotations.
|
||||||
class TlsContext {
|
// Fingerprint is the 32-byte SHA-256 of the public key.
|
||||||
public:
|
struct ServerIdentity {
|
||||||
// TODO(M1): client/server handshake; read/write; export_keying_material(label,...).
|
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
|
// ── Server TLS certificate ─────────────────────────────────────────────────────
|
||||||
// backend later, if a permissive impl matures) is swappable without touching voice code.
|
// 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 {
|
class MediaCrypto {
|
||||||
public:
|
public:
|
||||||
virtual ~MediaCrypto() = default;
|
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,
|
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;
|
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,
|
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
|
} // 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
|
#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"
|
#include "net/transport.h"
|
||||||
|
|
||||||
|
#ifdef VOICECAT_HAS_NET
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "crypto/crypto.h"
|
||||||
|
|
||||||
namespace voicecat::net {
|
namespace voicecat::net {
|
||||||
|
|
||||||
// M0 stub. Subsystem brought up in M1 (TCP/TLS) and M2 (UDP). See docs/protocol.md,
|
// ── TcpControlChannel ────────────────────────────────────────────────────────
|
||||||
// docs/voice.md, and AGENTS.md "Suggested first steps".
|
|
||||||
|
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
|
} // namespace voicecat::net
|
||||||
|
|
||||||
|
#endif // VOICECAT_HAS_NET
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/*
|
/*
|
||||||
* net/transport.h — TCP control channel + UDP media channel.
|
* net/transport.h — TCP control channel + UDP media channel.
|
||||||
*
|
*
|
||||||
* Design: docs/architecture.md (Net thread), docs/protocol.md §1 (framing), docs/voice.md §2
|
* Design: docs/architecture.md (Net thread), docs/protocol.md §1 (framing).
|
||||||
* (UDP frame). Implementation will use standalone Asio (one reactor) for sockets/timers.
|
* 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
|
#ifndef VOICECAT_NET_TRANSPORT_H
|
||||||
#define VOICECAT_NET_TRANSPORT_H
|
#define VOICECAT_NET_TRANSPORT_H
|
||||||
@@ -12,22 +13,161 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#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 {
|
namespace voicecat::net {
|
||||||
|
|
||||||
// Length-prefixed [u32 length][payload] framing over a TLS 1.3 byte stream (protocol.md §1).
|
// Callbacks delivered on the net thread. Callers must not block inside them.
|
||||||
class TcpControlChannel {
|
struct TcpChannelCallbacks {
|
||||||
public:
|
std::function<void()> on_connected;
|
||||||
// TODO(M1): connect(host, port), TLS handshake, send/recv framed Envelopes.
|
std::function<void(std::error_code)> on_connect_error;
|
||||||
bool connected() const { return connected_; }
|
std::function<void(std::vector<uint8_t>)> on_frame; // one decoded frame payload
|
||||||
|
std::function<void(std::error_code)> on_error;
|
||||||
private:
|
std::function<void()> on_disconnected;
|
||||||
bool connected_ = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 {
|
class UdpMediaChannel {
|
||||||
public:
|
public:
|
||||||
// TODO(M2): bind, send/recv AEAD-sealed voice frames, keepalive.
|
|
||||||
bool bound() const { return bound_; }
|
bool bound() const { return bound_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -36,4 +176,21 @@ class UdpMediaChannel {
|
|||||||
|
|
||||||
} // namespace voicecat::net
|
} // 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
|
#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 "protocol/protocol.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
namespace voicecat::protocol {
|
namespace voicecat::protocol {
|
||||||
|
|
||||||
// M0 stub. The frame codec + protobuf Envelope dispatch are the first M1 task
|
// --- FrameCodec::emit -------------------------------------------------------
|
||||||
// (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>>&) {
|
void FrameCodec::emit(const uint8_t* payload, size_t len, std::vector<uint8_t>& out) {
|
||||||
return true; // TODO(M1): real framing.
|
// 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
|
} // namespace voicecat::protocol
|
||||||
|
|||||||
@@ -17,15 +17,28 @@
|
|||||||
|
|
||||||
namespace voicecat::protocol {
|
namespace voicecat::protocol {
|
||||||
|
|
||||||
constexpr uint32_t kProtocolVersion = 1; // docs/protocol.md §4
|
constexpr uint32_t kProtocolVersion = 1;
|
||||||
constexpr uint32_t kMaxFrameBytes = 16u * 1024 * 1024; // §1 oversized-frame guard
|
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 {
|
class FrameCodec {
|
||||||
public:
|
public:
|
||||||
// Append received bytes; pop complete frame payloads. Returns false on protocol error
|
// Append received bytes; pop complete frame payloads into out_frames.
|
||||||
// (e.g. length > kMaxFrameBytes).
|
// 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);
|
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
|
} // namespace voicecat::protocol
|
||||||
|
|||||||
@@ -1,8 +1,87 @@
|
|||||||
#include "session/session.h"
|
#include "session/session.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
namespace voicecat::session {
|
namespace voicecat::session {
|
||||||
|
|
||||||
// M0 stub. Channel tree, users, streams, permissions, and ephemeral text relay land in M1.
|
const Channel* SessionModel::find_channel(uint32_t id) const {
|
||||||
// See docs/protocol.md §5.
|
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
|
} // 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
|
* Populated from ServerStateSnapshot (full snapshot) and incremental UserEvent /
|
||||||
* server state) and server (authoritative). Text is ephemeral (no history). Accounts are
|
* ChannelEvent messages. Not thread-safe — always called from io_thread_.
|
||||||
* admin-provisioned.
|
|
||||||
*
|
|
||||||
* STATUS: M0 stub.
|
|
||||||
*/
|
*/
|
||||||
#ifndef VOICECAT_SESSION_SESSION_H
|
#ifndef VOICECAT_SESSION_SESSION_H
|
||||||
#define VOICECAT_SESSION_SESSION_H
|
#define VOICECAT_SESSION_SESSION_H
|
||||||
@@ -14,40 +11,52 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#ifdef VOICECAT_HAS_NET
|
||||||
|
#include "proto/voicecat.pb.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace voicecat::session {
|
namespace voicecat::session {
|
||||||
|
|
||||||
struct Channel {
|
struct Channel {
|
||||||
uint32_t id = 0;
|
uint32_t id{0};
|
||||||
uint32_t parent_id = 0;
|
uint32_t parent_id{0};
|
||||||
std::string name;
|
std::string name;
|
||||||
bool password_protected = false;
|
bool password_protected{false};
|
||||||
uint32_t max_users = 0;
|
uint32_t max_users{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Stream {
|
struct Stream {
|
||||||
uint32_t stream_id = 0;
|
uint32_t stream_id{0};
|
||||||
uint32_t ssrc = 0;
|
uint32_t ssrc{0};
|
||||||
int kind = 0; // vc_stream_kind
|
int kind{0};
|
||||||
std::string label;
|
std::string label;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct User {
|
struct User {
|
||||||
uint32_t id = 0;
|
uint32_t id{0};
|
||||||
std::string nickname;
|
std::string nickname;
|
||||||
bool is_guest = true;
|
bool is_guest{true};
|
||||||
uint32_t channel_id = 0;
|
uint32_t channel_id{0};
|
||||||
std::vector<Stream> streams;
|
std::vector<Stream> streams;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mirror/authority for the channel tree + user list. TODO(M1): snapshot + delta apply.
|
|
||||||
class SessionModel {
|
class SessionModel {
|
||||||
public:
|
public:
|
||||||
const std::vector<Channel>& channels() const { return channels_; }
|
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:
|
private:
|
||||||
std::vector<Channel> channels_;
|
std::vector<Channel> channels_;
|
||||||
std::vector<User> users_;
|
std::vector<User> users_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace voicecat::session
|
} // namespace voicecat::session
|
||||||
|
|||||||
@@ -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")
|
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
|
||||||
|
list(FILTER VOICECAT_SERVER_LIB_SOURCES EXCLUDE REGEX ".*[/\\\\]main\\.cpp$")
|
||||||
|
|
||||||
add_executable(voicecat-server ${VOICECAT_SERVER_SOURCES})
|
add_library(voicecat-server-lib STATIC ${VOICECAT_SERVER_LIB_SOURCES})
|
||||||
target_link_libraries(voicecat-server PRIVATE voicecat::voicecat)
|
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)
|
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>
|
#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 {
|
namespace voicecat::server {
|
||||||
|
|
||||||
int Server::run() {
|
int Server::run() {
|
||||||
// M0 stub: report what a real run WILL do, then exit. M1 brings up the TLS listener,
|
// ── Identity + cert ──────────────────────────────────────────────────────
|
||||||
// session registry, and channel manager (docs/architecture.md §5).
|
ServerIdentityManager id_mgr;
|
||||||
std::printf(" server_name : %s\n", cfg_.server_name.c_str());
|
std::string error;
|
||||||
std::printf(" data_dir : %s\n", cfg_.data_dir.c_str());
|
if (!id_mgr.init(cfg_.data_dir, cfg_.server_name, error)) {
|
||||||
std::printf(" bind_port : %u (TCP control + UDP media)\n", cfg_.bind_port);
|
std::fprintf(stderr, "[server] identity init failed: %s\n", error.c_str());
|
||||||
std::printf(" allow_guests: %s\n", cfg_.allow_guests ? "true" : "false");
|
return 1;
|
||||||
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");
|
// ── 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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Server::stop() {
|
||||||
|
std::lock_guard lk(stop_mutex_);
|
||||||
|
if (stop_fn_) stop_fn_();
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace voicecat::server
|
} // 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.
|
* Design: docs/architecture.md §5. Headless process: TLS control listener,
|
||||||
* Responsibilities: connection manager (TLS), session registry, channel manager, text router,
|
* session registry, text relay, SQLite persistence. Zero-config first-run.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
#ifndef VOICECAT_SERVER_SERVER_H
|
#ifndef VOICECAT_SERVER_SERVER_H
|
||||||
#define VOICECAT_SERVER_SERVER_H
|
#define VOICECAT_SERVER_SERVER_H
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace voicecat::server {
|
namespace voicecat::server {
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
std::string server_name = "VoiceCat Server";
|
std::string server_name = "VoiceCat Server";
|
||||||
std::string data_dir = "voicecat-data";
|
std::string data_dir = "voicecat-data";
|
||||||
uint16_t bind_port = 8384; // TCP + UDP (docs/deployment.md §2)
|
uint16_t bind_port = 8384; // 0 = let OS pick (useful for tests)
|
||||||
bool allow_guests = true;
|
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 {
|
class Server {
|
||||||
public:
|
public:
|
||||||
explicit Server(Config cfg) : cfg_(std::move(cfg)) {}
|
explicit Server(Config cfg) : cfg_(std::move(cfg)) {}
|
||||||
|
|
||||||
// TODO(M1): bind TLS control listener + UDP media socket; run the Asio loop until stop.
|
// Block until the server shuts down. Returns process exit code.
|
||||||
// Returns process exit code.
|
|
||||||
int run();
|
int run();
|
||||||
|
|
||||||
|
// Thread-safe stop: unblocks run() from any thread. No-op if not running.
|
||||||
|
void stop();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Config cfg_;
|
Config cfg_;
|
||||||
|
std::mutex stop_mutex_;
|
||||||
|
std::function<void()> stop_fn_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace voicecat::server
|
} // 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
|
||||||
@@ -1,8 +1,42 @@
|
|||||||
# Tests use plain asserts + exit codes for now (no framework dependency in the skeleton).
|
# Tests use plain asserts + exit codes (no framework dep needed).
|
||||||
# A real framework (e.g. Catch2/GoogleTest via vcpkg) can be added when VOICECAT_USE_VCPKG_DEPS
|
# Behavior tests — not just "it compiles" — are how milestones are judged (AGENTS.md).
|
||||||
# is on. Behavior tests — not just "it compiles" — are how milestones are judged (AGENTS.md).
|
|
||||||
|
|
||||||
add_executable(test_smoke test_smoke.cpp)
|
add_executable(test_smoke test_smoke.cpp)
|
||||||
target_link_libraries(test_smoke PRIVATE voicecat::voicecat)
|
target_link_libraries(test_smoke PRIVATE voicecat::voicecat)
|
||||||
target_compile_features(test_smoke PRIVATE cxx_std_20)
|
target_compile_features(test_smoke PRIVATE cxx_std_20)
|
||||||
add_test(NAME smoke COMMAND test_smoke)
|
add_test(NAME smoke COMMAND test_smoke)
|
||||||
|
|
||||||
|
# frame_codec has no third-party deps; runs under both dev and m1-dev.
|
||||||
|
# Needs core/src on the include path to reach internal headers (protocol/, session/, etc.).
|
||||||
|
add_executable(test_frame_codec test_frame_codec.cpp)
|
||||||
|
target_link_libraries(test_frame_codec PRIVATE voicecat::voicecat)
|
||||||
|
target_compile_features(test_frame_codec PRIVATE cxx_std_20)
|
||||||
|
target_include_directories(test_frame_codec PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
|
||||||
|
add_test(NAME frame_codec COMMAND test_frame_codec)
|
||||||
|
|
||||||
|
if(VOICECAT_USE_VCPKG_DEPS)
|
||||||
|
set(VC_TEST_INTERNAL_INCLUDES
|
||||||
|
${CMAKE_SOURCE_DIR}/core/src
|
||||||
|
${CMAKE_SOURCE_DIR}/server/src
|
||||||
|
${CMAKE_BINARY_DIR}/core/generated) # protobuf-generated headers
|
||||||
|
|
||||||
|
add_executable(test_envelope test_envelope.cpp)
|
||||||
|
target_link_libraries(test_envelope PRIVATE voicecat::voicecat)
|
||||||
|
target_compile_features(test_envelope PRIVATE cxx_std_20)
|
||||||
|
target_include_directories(test_envelope PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||||
|
add_test(NAME envelope COMMAND test_envelope)
|
||||||
|
|
||||||
|
add_executable(test_tls_loopback test_tls_loopback.cpp)
|
||||||
|
target_link_libraries(test_tls_loopback PRIVATE voicecat::voicecat)
|
||||||
|
target_compile_features(test_tls_loopback PRIVATE cxx_std_20)
|
||||||
|
target_include_directories(test_tls_loopback PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||||
|
add_test(NAME tls_loopback COMMAND test_tls_loopback)
|
||||||
|
|
||||||
|
# Links voicecat::server (which pulls in voicecat::voicecat + all deps transitively).
|
||||||
|
add_executable(test_m1_integration test_m1_integration.cpp)
|
||||||
|
target_link_libraries(test_m1_integration PRIVATE voicecat::server)
|
||||||
|
target_compile_features(test_m1_integration PRIVATE cxx_std_20)
|
||||||
|
target_include_directories(test_m1_integration PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||||
|
add_test(NAME m1_integration COMMAND test_m1_integration)
|
||||||
|
set_tests_properties(m1_integration PROPERTIES TIMEOUT 60)
|
||||||
|
endif()
|
||||||
|
|||||||
86
tests/test_envelope.cpp
Normal file
86
tests/test_envelope.cpp
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* test_envelope — round-trip an Envelope through FrameCodec + encode/decode.
|
||||||
|
* Runs only under m1-dev (requires protobuf).
|
||||||
|
*/
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "protocol/envelope.h"
|
||||||
|
#include "protocol/protocol.h"
|
||||||
|
|
||||||
|
using namespace voicecat::protocol;
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
#define CHECK(cond) \
|
||||||
|
do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||||
|
++g_failures; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// Build a ClientHello envelope.
|
||||||
|
voicecat::v1::Envelope out_env;
|
||||||
|
out_env.set_request_id(42);
|
||||||
|
auto* hello = out_env.mutable_client_hello();
|
||||||
|
hello->set_proto_version(1);
|
||||||
|
hello->set_client_name("test-client");
|
||||||
|
hello->set_client_version("0.0.1");
|
||||||
|
hello->add_features("text");
|
||||||
|
|
||||||
|
// encode_envelope → framed wire bytes
|
||||||
|
std::vector<uint8_t> wire;
|
||||||
|
bool enc_ok = encode_envelope(out_env, wire);
|
||||||
|
CHECK(enc_ok);
|
||||||
|
CHECK(wire.size() > kLengthHeaderSize);
|
||||||
|
|
||||||
|
// Feed through FrameCodec to extract the payload
|
||||||
|
FrameCodec codec;
|
||||||
|
std::vector<std::vector<uint8_t>> frames;
|
||||||
|
bool feed_ok = codec.feed(wire.data(), wire.size(), frames);
|
||||||
|
CHECK(feed_ok);
|
||||||
|
CHECK(frames.size() == 1);
|
||||||
|
|
||||||
|
// decode_envelope from the extracted payload
|
||||||
|
voicecat::v1::Envelope in_env;
|
||||||
|
bool dec_ok = decode_envelope(frames[0], in_env);
|
||||||
|
CHECK(dec_ok);
|
||||||
|
|
||||||
|
// Verify round-trip fidelity
|
||||||
|
CHECK(in_env.request_id() == 42);
|
||||||
|
CHECK(in_env.has_client_hello());
|
||||||
|
CHECK(in_env.client_hello().proto_version() == 1);
|
||||||
|
CHECK(std::strcmp(in_env.client_hello().client_name().c_str(), "test-client") == 0);
|
||||||
|
CHECK(in_env.client_hello().features_size() == 1);
|
||||||
|
CHECK(std::strcmp(in_env.client_hello().features(0).c_str(), "text") == 0);
|
||||||
|
|
||||||
|
// next_request_id() is monotonically increasing
|
||||||
|
uint64_t r1 = next_request_id();
|
||||||
|
uint64_t r2 = next_request_id();
|
||||||
|
CHECK(r2 == r1 + 1);
|
||||||
|
|
||||||
|
// Empty envelope round-trips cleanly
|
||||||
|
{
|
||||||
|
voicecat::v1::Envelope empty;
|
||||||
|
std::vector<uint8_t> w2;
|
||||||
|
CHECK(encode_envelope(empty, w2));
|
||||||
|
FrameCodec c2;
|
||||||
|
std::vector<std::vector<uint8_t>> f2;
|
||||||
|
CHECK(c2.feed(w2.data(), w2.size(), f2));
|
||||||
|
CHECK(f2.size() == 1);
|
||||||
|
voicecat::v1::Envelope e2;
|
||||||
|
CHECK(decode_envelope(f2[0], e2));
|
||||||
|
CHECK(e2.request_id() == 0);
|
||||||
|
CHECK(e2.body_case() == voicecat::v1::Envelope::BODY_NOT_SET);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
std::printf("envelope: all checks passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("envelope: %d failure(s)\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
150
tests/test_frame_codec.cpp
Normal file
150
tests/test_frame_codec.cpp
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* test_frame_codec — unit test for FrameCodec::feed / ::emit.
|
||||||
|
*
|
||||||
|
* No third-party dependencies; runs under both the dev and m1-dev presets.
|
||||||
|
* Tests: empty payload, single byte, 64 KiB, exact max-size, oversized (should reject),
|
||||||
|
* split delivery (bytes fed one-at-a-time), and batched multi-frame delivery.
|
||||||
|
*/
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "protocol/protocol.h"
|
||||||
|
|
||||||
|
using namespace voicecat::protocol;
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
#define CHECK(cond) \
|
||||||
|
do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||||
|
++g_failures; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
// Round-trip a single payload through emit → feed.
|
||||||
|
static void round_trip(const std::vector<uint8_t>& payload, const char* label) {
|
||||||
|
std::vector<uint8_t> wire;
|
||||||
|
FrameCodec::emit(payload, wire);
|
||||||
|
|
||||||
|
FrameCodec codec;
|
||||||
|
std::vector<std::vector<uint8_t>> frames;
|
||||||
|
bool ok = codec.feed(wire.data(), wire.size(), frames);
|
||||||
|
CHECK(ok);
|
||||||
|
CHECK(frames.size() == 1);
|
||||||
|
if (!frames.empty()) {
|
||||||
|
CHECK(frames[0] == payload);
|
||||||
|
}
|
||||||
|
(void)label;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// --- Empty payload ---
|
||||||
|
round_trip({}, "empty");
|
||||||
|
|
||||||
|
// --- Single byte ---
|
||||||
|
round_trip({0xAB}, "1 byte");
|
||||||
|
|
||||||
|
// --- 64 KiB payload ---
|
||||||
|
{
|
||||||
|
std::vector<uint8_t> big(64 * 1024);
|
||||||
|
for (size_t i = 0; i < big.size(); ++i) big[i] = static_cast<uint8_t>(i & 0xFF);
|
||||||
|
round_trip(big, "64 KiB");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Exactly kMaxFrameBytes ---
|
||||||
|
{
|
||||||
|
std::vector<uint8_t> max_payload(kMaxFrameBytes, 0x5A);
|
||||||
|
std::vector<uint8_t> wire;
|
||||||
|
FrameCodec::emit(max_payload, wire);
|
||||||
|
|
||||||
|
FrameCodec codec;
|
||||||
|
std::vector<std::vector<uint8_t>> frames;
|
||||||
|
bool ok = codec.feed(wire.data(), wire.size(), frames);
|
||||||
|
CHECK(ok);
|
||||||
|
CHECK(frames.size() == 1);
|
||||||
|
if (!frames.empty()) CHECK(frames[0] == max_payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- One byte over kMaxFrameBytes — must be rejected ---
|
||||||
|
{
|
||||||
|
// Craft a fake header with length = kMaxFrameBytes + 1.
|
||||||
|
uint32_t bad_len = kMaxFrameBytes + 1;
|
||||||
|
uint8_t header[4] = {
|
||||||
|
static_cast<uint8_t>((bad_len >> 24) & 0xFF),
|
||||||
|
static_cast<uint8_t>((bad_len >> 16) & 0xFF),
|
||||||
|
static_cast<uint8_t>((bad_len >> 8) & 0xFF),
|
||||||
|
static_cast<uint8_t>( bad_len & 0xFF),
|
||||||
|
};
|
||||||
|
FrameCodec codec;
|
||||||
|
std::vector<std::vector<uint8_t>> frames;
|
||||||
|
bool ok = codec.feed(header, 4, frames);
|
||||||
|
CHECK(!ok); // must return false
|
||||||
|
CHECK(frames.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Byte-at-a-time delivery (reassembly) ---
|
||||||
|
{
|
||||||
|
std::vector<uint8_t> payload = {1, 2, 3, 4, 5};
|
||||||
|
std::vector<uint8_t> wire;
|
||||||
|
FrameCodec::emit(payload, wire);
|
||||||
|
|
||||||
|
FrameCodec codec;
|
||||||
|
std::vector<std::vector<uint8_t>> frames;
|
||||||
|
bool ok = true;
|
||||||
|
for (uint8_t b : wire) {
|
||||||
|
ok = codec.feed(&b, 1, frames);
|
||||||
|
if (!ok) break;
|
||||||
|
}
|
||||||
|
CHECK(ok);
|
||||||
|
CHECK(frames.size() == 1);
|
||||||
|
if (!frames.empty()) CHECK(frames[0] == payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Multiple frames in a single feed() call ---
|
||||||
|
{
|
||||||
|
std::vector<uint8_t> p1 = {0x01, 0x02};
|
||||||
|
std::vector<uint8_t> p2 = {0xAA, 0xBB, 0xCC};
|
||||||
|
std::vector<uint8_t> wire;
|
||||||
|
FrameCodec::emit(p1, wire);
|
||||||
|
FrameCodec::emit(p2, wire);
|
||||||
|
|
||||||
|
FrameCodec codec;
|
||||||
|
std::vector<std::vector<uint8_t>> frames;
|
||||||
|
bool ok = codec.feed(wire.data(), wire.size(), frames);
|
||||||
|
CHECK(ok);
|
||||||
|
CHECK(frames.size() == 2);
|
||||||
|
if (frames.size() == 2) {
|
||||||
|
CHECK(frames[0] == p1);
|
||||||
|
CHECK(frames[1] == p2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pending_bytes() reflects partial state ---
|
||||||
|
{
|
||||||
|
std::vector<uint8_t> payload = {0xFF};
|
||||||
|
std::vector<uint8_t> wire;
|
||||||
|
FrameCodec::emit(payload, wire); // 5 bytes total (4 hdr + 1)
|
||||||
|
|
||||||
|
FrameCodec codec;
|
||||||
|
std::vector<std::vector<uint8_t>> frames;
|
||||||
|
|
||||||
|
// Feed only the header.
|
||||||
|
codec.feed(wire.data(), 4, frames);
|
||||||
|
CHECK(frames.empty());
|
||||||
|
CHECK(codec.pending_bytes() == 4);
|
||||||
|
|
||||||
|
// Feed the body.
|
||||||
|
codec.feed(wire.data() + 4, 1, frames);
|
||||||
|
CHECK(frames.size() == 1);
|
||||||
|
CHECK(codec.pending_bytes() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
std::printf("frame_codec: all checks passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("frame_codec: %d failure(s)\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
256
tests/test_m1_integration.cpp
Normal file
256
tests/test_m1_integration.cpp
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/*
|
||||||
|
* test_m1_integration — M1 exit criterion.
|
||||||
|
*
|
||||||
|
* Two clients connect to a real voicecat-server over TLS 1.3:
|
||||||
|
* Client A authenticates as guest "GuestBob"
|
||||||
|
* Client B authenticates as password user "alice"
|
||||||
|
* Both receive the channel list, A sends a channel message that B receives,
|
||||||
|
* then B sends a private message that A receives.
|
||||||
|
*/
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#ifdef VOICECAT_HAS_NET
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "voicecat.h"
|
||||||
|
#include "server.h"
|
||||||
|
#include "db.h"
|
||||||
|
|
||||||
|
// ── Event tracking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct EventStore {
|
||||||
|
std::mutex mu;
|
||||||
|
std::condition_variable cv;
|
||||||
|
|
||||||
|
bool auth_ok{false};
|
||||||
|
vc_result auth_result{VC_ERR_INTERNAL};
|
||||||
|
uint32_t self_user_id{0};
|
||||||
|
bool channel_list_received{false};
|
||||||
|
std::vector<std::string> messages; // copies of received text bodies
|
||||||
|
|
||||||
|
// For diagnostics
|
||||||
|
const char* label{nullptr};
|
||||||
|
std::string last_error;
|
||||||
|
bool disconnected{false};
|
||||||
|
vc_connection_state last_state{VC_STATE_DISCONNECTED};
|
||||||
|
};
|
||||||
|
|
||||||
|
static void on_event(void* user, const vc_event* ev) {
|
||||||
|
auto* s = static_cast<EventStore*>(user);
|
||||||
|
std::lock_guard lk(s->mu);
|
||||||
|
s->last_state = ev->connection_state;
|
||||||
|
switch (ev->type) {
|
||||||
|
case VC_EVENT_AUTH_RESULT:
|
||||||
|
s->auth_result = static_cast<vc_result>(ev->result);
|
||||||
|
s->auth_ok = (ev->result == VC_OK);
|
||||||
|
s->self_user_id = ev->user_id;
|
||||||
|
if (!s->auth_ok) std::fprintf(stderr, "[%s] AUTH FAILED: %s\n",
|
||||||
|
s->label ? s->label : "?", ev->text ? ev->text : "(no msg)");
|
||||||
|
break;
|
||||||
|
case VC_EVENT_CHANNEL_LIST:
|
||||||
|
s->channel_list_received = true;
|
||||||
|
break;
|
||||||
|
case VC_EVENT_TEXT_MESSAGE:
|
||||||
|
if (ev->text) s->messages.emplace_back(ev->text);
|
||||||
|
break;
|
||||||
|
case VC_EVENT_ERROR:
|
||||||
|
s->last_error = ev->text ? ev->text : "";
|
||||||
|
std::fprintf(stderr, "[%s] ERROR rc=%d: %s\n",
|
||||||
|
s->label ? s->label : "?", ev->result, s->last_error.c_str());
|
||||||
|
break;
|
||||||
|
case VC_EVENT_DISCONNECTED:
|
||||||
|
s->disconnected = true;
|
||||||
|
std::fprintf(stderr, "[%s] DISCONNECTED rc=%d: %s\n",
|
||||||
|
s->label ? s->label : "?", ev->result, ev->text ? ev->text : "");
|
||||||
|
break;
|
||||||
|
case VC_EVENT_CONNECTION_STATE:
|
||||||
|
std::fprintf(stderr, "[%s] STATE -> %d\n",
|
||||||
|
s->label ? s->label : "?", (int)ev->connection_state);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
s->cv.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename Pred>
|
||||||
|
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
||||||
|
auto deadline = std::chrono::steady_clock::now() +
|
||||||
|
std::chrono::milliseconds(timeout_ms);
|
||||||
|
std::unique_lock lk(s.mu);
|
||||||
|
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test harness ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
#define CHECK(cond) \
|
||||||
|
do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
||||||
|
++g_failures; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
// ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// ── Isolated temp dir for this test run ──────────────────────────────────
|
||||||
|
auto tmp = std::filesystem::temp_directory_path() /
|
||||||
|
("vctest_" + std::to_string(
|
||||||
|
std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||||
|
std::filesystem::create_directories(tmp);
|
||||||
|
std::string data_dir = tmp.string();
|
||||||
|
|
||||||
|
// ── Pre-provision alice's account before the server starts ───────────────
|
||||||
|
{
|
||||||
|
voicecat::server::Database db(data_dir + "/voicecat.db");
|
||||||
|
std::string err;
|
||||||
|
if (!db.open(err)) {
|
||||||
|
std::printf("FAIL: db.open: %s\n", err.c_str());
|
||||||
|
std::filesystem::remove_all(tmp);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
auto acc = db.create_account("alice", "test-pass-alice", false, err);
|
||||||
|
if (!acc) {
|
||||||
|
std::printf("FAIL: create_account: %s\n", err.c_str());
|
||||||
|
std::filesystem::remove_all(tmp);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start server on an OS-assigned port ───────────────────────────────────
|
||||||
|
std::atomic<uint16_t> bound_port{0};
|
||||||
|
std::mutex ready_mu;
|
||||||
|
std::condition_variable ready_cv;
|
||||||
|
bool ready{false};
|
||||||
|
|
||||||
|
voicecat::server::Config cfg;
|
||||||
|
cfg.data_dir = data_dir;
|
||||||
|
cfg.bind_port = 0; // OS picks port
|
||||||
|
cfg.server_name = "VoiceCat-IntTest";
|
||||||
|
cfg.allow_guests = true;
|
||||||
|
cfg.on_ready = [&](uint16_t p) {
|
||||||
|
bound_port.store(p);
|
||||||
|
{ std::lock_guard lk(ready_mu); ready = true; }
|
||||||
|
ready_cv.notify_all();
|
||||||
|
};
|
||||||
|
|
||||||
|
voicecat::server::Server server(cfg);
|
||||||
|
std::thread server_thread([&] { server.run(); });
|
||||||
|
|
||||||
|
{
|
||||||
|
std::unique_lock lk(ready_mu);
|
||||||
|
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10),
|
||||||
|
[&] { return ready; });
|
||||||
|
if (!ok) {
|
||||||
|
std::printf("FAIL: server did not become ready within 10s\n");
|
||||||
|
server.stop();
|
||||||
|
server_thread.join();
|
||||||
|
std::filesystem::remove_all(tmp);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t port = bound_port.load();
|
||||||
|
std::printf("m1_integration: server ready on :%u\n", port);
|
||||||
|
|
||||||
|
// ── Client A: guest "GuestBob" ────────────────────────────────────────────
|
||||||
|
EventStore evA;
|
||||||
|
evA.label = "clientA";
|
||||||
|
vc_callbacks cbA{on_event, nullptr, &evA};
|
||||||
|
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
||||||
|
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||||
|
CHECK(clientA != nullptr);
|
||||||
|
|
||||||
|
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||||
|
CHECK(vc_authenticate_guest(clientA, "GuestBob") == VC_OK);
|
||||||
|
|
||||||
|
// Guest auth is fast; 8s is generous.
|
||||||
|
bool authA_ok = wait_for(evA, [](EventStore& s){ return s.auth_ok; }, 8000);
|
||||||
|
CHECK(authA_ok);
|
||||||
|
if (!authA_ok) std::printf(" (client A auth timed out)\n");
|
||||||
|
|
||||||
|
bool clA_ok = wait_for(evA, [](EventStore& s){ return s.channel_list_received; }, 3000);
|
||||||
|
CHECK(clA_ok);
|
||||||
|
|
||||||
|
// ── Client B: password user "alice" ───────────────────────────────────────
|
||||||
|
EventStore evB;
|
||||||
|
evB.label = "clientB";
|
||||||
|
vc_callbacks cbB{on_event, nullptr, &evB};
|
||||||
|
vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
|
||||||
|
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||||
|
CHECK(clientB != nullptr);
|
||||||
|
|
||||||
|
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||||
|
CHECK(vc_authenticate_user(clientB, "alice", "test-pass-alice") == VC_OK);
|
||||||
|
|
||||||
|
// Argon2id (INTERACTIVE) takes ~0.5-2 s; allow 20 s.
|
||||||
|
bool authB_ok = wait_for(evB, [](EventStore& s){ return s.auth_ok; }, 20000);
|
||||||
|
CHECK(authB_ok);
|
||||||
|
if (!authB_ok) std::printf(" (client B auth timed out — Argon2id may be slow)\n");
|
||||||
|
|
||||||
|
bool clB_ok = wait_for(evB, [](EventStore& s){ return s.channel_list_received; }, 3000);
|
||||||
|
CHECK(clB_ok);
|
||||||
|
|
||||||
|
// ── A sends channel text → B receives it ─────────────────────────────────
|
||||||
|
const char* chan_msg = "Hello from GuestBob!";
|
||||||
|
CHECK(vc_send_text(clientA, VC_TEXT_CHANNEL, 1, chan_msg) == VC_OK);
|
||||||
|
|
||||||
|
bool B_got_chan = wait_for(evB, [&](EventStore& s) {
|
||||||
|
for (auto& m : s.messages)
|
||||||
|
if (m == chan_msg) return true;
|
||||||
|
return false;
|
||||||
|
}, 5000);
|
||||||
|
CHECK(B_got_chan);
|
||||||
|
|
||||||
|
// ── B sends private text to A ─────────────────────────────────────────────
|
||||||
|
uint32_t a_uid = 0;
|
||||||
|
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
||||||
|
|
||||||
|
const char* priv_msg = "Private reply from alice!";
|
||||||
|
CHECK(vc_send_text(clientB, VC_TEXT_PRIVATE, a_uid, priv_msg) == VC_OK);
|
||||||
|
|
||||||
|
bool A_got_priv = wait_for(evA, [&](EventStore& s) {
|
||||||
|
for (auto& m : s.messages)
|
||||||
|
if (m == priv_msg) return true;
|
||||||
|
return false;
|
||||||
|
}, 5000);
|
||||||
|
CHECK(A_got_priv);
|
||||||
|
|
||||||
|
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||||
|
vc_disconnect(clientA);
|
||||||
|
vc_disconnect(clientB);
|
||||||
|
vc_client_destroy(clientA);
|
||||||
|
vc_client_destroy(clientB);
|
||||||
|
|
||||||
|
server.stop();
|
||||||
|
server_thread.join();
|
||||||
|
|
||||||
|
std::filesystem::remove_all(tmp);
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
std::printf("m1_integration: all checks passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("m1_integration: %d failure(s)\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else // !VOICECAT_HAS_NET
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
std::printf("m1_integration: SKIP (VOICECAT_HAS_NET not defined)\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // VOICECAT_HAS_NET
|
||||||
@@ -43,10 +43,22 @@ int main() {
|
|||||||
CHECK(vc_connect(c, nullptr, 1) == VC_ERR_INVALID_ARG);
|
CHECK(vc_connect(c, nullptr, 1) == VC_ERR_INVALID_ARG);
|
||||||
CHECK(vc_send_text(c, VC_TEXT_CHANNEL, 0, nullptr) == VC_ERR_INVALID_ARG);
|
CHECK(vc_send_text(c, VC_TEXT_CHANNEL, 0, nullptr) == VC_ERR_INVALID_ARG);
|
||||||
|
|
||||||
// Unimplemented subsystems report NOT_IMPLEMENTED (not a crash) in the M0 skeleton.
|
// Under dev preset: NOT_IMPLEMENTED. Under m1-dev: VC_OK (async connect).
|
||||||
CHECK(vc_connect(c, "127.0.0.1", 8384) == VC_ERR_NOT_IMPLEMENTED);
|
vc_result rc_connect = vc_connect(c, "127.0.0.1", 8384);
|
||||||
CHECK(vc_authenticate_guest(c, "nick") == VC_ERR_NOT_IMPLEMENTED);
|
CHECK(rc_connect == VC_ERR_NOT_IMPLEMENTED || rc_connect == VC_OK);
|
||||||
CHECK(vc_join_channel(c, 1, nullptr) == VC_ERR_NOT_IMPLEMENTED);
|
|
||||||
|
// Auth before connected (or on a stub) → NOT_CONNECTED or NOT_IMPLEMENTED.
|
||||||
|
{
|
||||||
|
vc_config cfg2 = cfg;
|
||||||
|
vc_client* c2 = vc_client_create(&cfg2, cb);
|
||||||
|
vc_result rc_auth = vc_authenticate_guest(c2, "nick");
|
||||||
|
CHECK(rc_auth == VC_ERR_NOT_IMPLEMENTED || rc_auth == VC_ERR_NOT_CONNECTED);
|
||||||
|
vc_client_destroy(c2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// join_channel before connected → NOT_CONNECTED or NOT_IMPLEMENTED.
|
||||||
|
vc_result rc_join = vc_join_channel(c, 1, nullptr);
|
||||||
|
CHECK(rc_join == VC_ERR_NOT_IMPLEMENTED || rc_join == VC_ERR_NOT_CONNECTED);
|
||||||
|
|
||||||
vc_device_list dl;
|
vc_device_list dl;
|
||||||
CHECK(vc_list_devices(c, VC_DEVICE_INPUT, &dl) == VC_ERR_NOT_IMPLEMENTED);
|
CHECK(vc_list_devices(c, VC_DEVICE_INPUT, &dl) == VC_ERR_NOT_IMPLEMENTED);
|
||||||
|
|||||||
105
tests/test_tcp_loopback.cpp
Normal file
105
tests/test_tcp_loopback.cpp
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* test_tcp_loopback — in-process TCP acceptor + client, sends 10 frames.
|
||||||
|
* Runs only under m1-dev (requires Asio).
|
||||||
|
*/
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "net/transport.h"
|
||||||
|
|
||||||
|
using namespace voicecat::net;
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
#define CHECK(cond) \
|
||||||
|
do { \
|
||||||
|
if (!(cond)) { \
|
||||||
|
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
|
||||||
|
++g_failures; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
constexpr int kFrameCount = 10;
|
||||||
|
constexpr uint16_t kPort = 19850;
|
||||||
|
|
||||||
|
std::mutex mtx;
|
||||||
|
std::condition_variable cv;
|
||||||
|
std::vector<std::vector<uint8_t>> received;
|
||||||
|
std::atomic<bool> server_connected{false};
|
||||||
|
|
||||||
|
// Server io_context + acceptor.
|
||||||
|
asio::io_context server_io;
|
||||||
|
auto work = asio::make_work_guard(server_io);
|
||||||
|
std::thread server_thread([&] { server_io.run(); });
|
||||||
|
|
||||||
|
TcpAcceptor acceptor(server_io, kPort, [&](asio::ip::tcp::socket sock) {
|
||||||
|
TcpChannelCallbacks cbs;
|
||||||
|
cbs.on_frame = [&](std::vector<uint8_t> frame) {
|
||||||
|
std::lock_guard<std::mutex> lk(mtx);
|
||||||
|
received.push_back(std::move(frame));
|
||||||
|
cv.notify_all();
|
||||||
|
};
|
||||||
|
cbs.on_connected = [&] { server_connected.store(true); };
|
||||||
|
auto conn = std::make_shared<TcpServerConn>(std::move(sock), std::move(cbs));
|
||||||
|
return conn;
|
||||||
|
});
|
||||||
|
acceptor.start();
|
||||||
|
|
||||||
|
// Client.
|
||||||
|
std::atomic<bool> client_connected{false};
|
||||||
|
TcpChannelCallbacks client_cbs;
|
||||||
|
client_cbs.on_connected = [&] { client_connected.store(true); };
|
||||||
|
client_cbs.on_connect_error = [](std::error_code ec) {
|
||||||
|
std::printf("connect error: %s\n", ec.message().c_str());
|
||||||
|
};
|
||||||
|
|
||||||
|
TcpControlChannel client(std::move(client_cbs));
|
||||||
|
client.async_connect("127.0.0.1", kPort);
|
||||||
|
|
||||||
|
// Wait for connection.
|
||||||
|
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||||
|
while (!client_connected.load() &&
|
||||||
|
std::chrono::steady_clock::now() < deadline) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
|
}
|
||||||
|
CHECK(client_connected.load());
|
||||||
|
|
||||||
|
// Send kFrameCount distinct frames.
|
||||||
|
for (int i = 0; i < kFrameCount; ++i) {
|
||||||
|
std::vector<uint8_t> payload = {static_cast<uint8_t>(i), 0xAB, 0xCD};
|
||||||
|
client.send_frame(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all frames to arrive on the server side.
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lk(mtx);
|
||||||
|
bool ok = cv.wait_for(lk, std::chrono::seconds(5),
|
||||||
|
[&] { return static_cast<int>(received.size()) >= kFrameCount; });
|
||||||
|
CHECK(ok);
|
||||||
|
}
|
||||||
|
CHECK(static_cast<int>(received.size()) == kFrameCount);
|
||||||
|
for (int i = 0; i < kFrameCount && i < static_cast<int>(received.size()); ++i) {
|
||||||
|
CHECK(received[i].size() == 3);
|
||||||
|
if (!received[i].empty()) CHECK(received[i][0] == static_cast<uint8_t>(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up.
|
||||||
|
client.close();
|
||||||
|
acceptor.stop();
|
||||||
|
work.reset();
|
||||||
|
server_io.stop();
|
||||||
|
server_thread.join();
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
std::printf("tcp_loopback: all checks passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("tcp_loopback: %d failure(s)\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
186
tests/test_tls_loopback.cpp
Normal file
186
tests/test_tls_loopback.cpp
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/*
|
||||||
|
* test_tls_loopback — in-process TLS 1.3 server + client over a loopback TCP socket pair.
|
||||||
|
* Validates: cert generation, handshake, ServerIdentity fingerprint, framed message exchange.
|
||||||
|
*/
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
# include <winsock2.h>
|
||||||
|
# include <ws2tcpip.h>
|
||||||
|
# pragma comment(lib, "ws2_32.lib")
|
||||||
|
using sock_t = SOCKET;
|
||||||
|
static constexpr sock_t kBadSock = INVALID_SOCKET;
|
||||||
|
static void close_sock(sock_t s) { closesocket(s); }
|
||||||
|
static int last_err() { return WSAGetLastError(); }
|
||||||
|
#else
|
||||||
|
# include <arpa/inet.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); }
|
||||||
|
static int last_err() { return errno; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "crypto/crypto.h"
|
||||||
|
|
||||||
|
using namespace voicecat::crypto;
|
||||||
|
|
||||||
|
static int g_failures = 0;
|
||||||
|
|
||||||
|
#define CHECK(cond) \
|
||||||
|
do { if (!(cond)) { std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); ++g_failures; } } while (0)
|
||||||
|
|
||||||
|
// Create a blocking loopback TCP socket pair: returns {server_fd, client_fd}
|
||||||
|
static std::pair<sock_t, sock_t> make_socket_pair(uint16_t port) {
|
||||||
|
sock_t listener = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (listener == kBadSock) return {kBadSock, kBadSock};
|
||||||
|
|
||||||
|
int opt = 1;
|
||||||
|
setsockopt(listener, SOL_SOCKET, SO_REUSEADDR,
|
||||||
|
reinterpret_cast<const char*>(&opt), sizeof(opt));
|
||||||
|
|
||||||
|
sockaddr_in addr{};
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||||
|
addr.sin_port = htons(port);
|
||||||
|
if (::bind(listener, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
||||||
|
close_sock(listener); return {kBadSock, kBadSock};
|
||||||
|
}
|
||||||
|
if (::listen(listener, 1) != 0) {
|
||||||
|
close_sock(listener); return {kBadSock, kBadSock};
|
||||||
|
}
|
||||||
|
|
||||||
|
sock_t client = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (client == kBadSock) { close_sock(listener); return {kBadSock, kBadSock}; }
|
||||||
|
if (::connect(client, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
||||||
|
close_sock(listener); close_sock(client); return {kBadSock, kBadSock};
|
||||||
|
}
|
||||||
|
|
||||||
|
sockaddr_in peer{};
|
||||||
|
socklen_t plen = sizeof(peer);
|
||||||
|
sock_t server = ::accept(listener, reinterpret_cast<sockaddr*>(&peer), &plen);
|
||||||
|
close_sock(listener);
|
||||||
|
if (server == kBadSock) { close_sock(client); return {kBadSock, kBadSock}; }
|
||||||
|
return {server, client};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write all bytes to a TLS context.
|
||||||
|
static bool tls_write_all(TlsContext& tls, const uint8_t* data, size_t len) {
|
||||||
|
size_t off = 0;
|
||||||
|
while (off < len) {
|
||||||
|
int n = tls.write(data + off, len - off);
|
||||||
|
if (n <= 0) return false;
|
||||||
|
off += n;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read exactly len bytes from a TLS context.
|
||||||
|
static bool tls_read_exact(TlsContext& tls, uint8_t* buf, size_t len) {
|
||||||
|
size_t off = 0;
|
||||||
|
while (off < len) {
|
||||||
|
int n = tls.read(buf + off, len - off);
|
||||||
|
if (n <= 0) return false;
|
||||||
|
off += n;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
#ifdef _WIN32
|
||||||
|
WSADATA wsa{};
|
||||||
|
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
|
||||||
|
std::printf("WSAStartup failed\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Generate server identity + cert
|
||||||
|
ServerIdentity identity = ServerIdentity::generate();
|
||||||
|
ServerCert cert = ServerCert::generate("test-server");
|
||||||
|
|
||||||
|
CHECK(!cert.pem_cert.empty());
|
||||||
|
CHECK(!cert.pem_key.empty());
|
||||||
|
|
||||||
|
auto [server_fd_native, client_fd_native] = make_socket_pair(19851);
|
||||||
|
CHECK(server_fd_native != kBadSock);
|
||||||
|
CHECK(client_fd_native != kBadSock);
|
||||||
|
if (server_fd_native == kBadSock || client_fd_native == kBadSock) {
|
||||||
|
std::printf("tls_loopback: socket pair failed (err=%d)\n", last_err());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string server_error, client_error;
|
||||||
|
std::atomic<bool> server_ok{false}, client_ok{false};
|
||||||
|
|
||||||
|
static const char kMsg1[] = "hello from server";
|
||||||
|
static const char kMsg2[] = "hello from client";
|
||||||
|
constexpr size_t kMsg1Len = sizeof(kMsg1) - 1;
|
||||||
|
constexpr size_t kMsg2Len = sizeof(kMsg2) - 1;
|
||||||
|
|
||||||
|
char client_recv[64]{};
|
||||||
|
char server_recv[64]{};
|
||||||
|
|
||||||
|
// Server thread: handshake, send msg1, recv msg2
|
||||||
|
std::thread server_thr([&] {
|
||||||
|
TlsContext tls(TlsContext::Role::Server, &cert);
|
||||||
|
int fd = static_cast<int>(server_fd_native);
|
||||||
|
if (!tls.handshake(fd, server_error)) { close_sock(server_fd_native); return; }
|
||||||
|
server_ok.store(true);
|
||||||
|
tls_write_all(tls, reinterpret_cast<const uint8_t*>(kMsg1), kMsg1Len);
|
||||||
|
tls_read_exact(tls, reinterpret_cast<uint8_t*>(server_recv), kMsg2Len);
|
||||||
|
close_sock(server_fd_native);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client thread: handshake, recv msg1, send msg2
|
||||||
|
std::thread client_thr([&] {
|
||||||
|
TlsContext tls(TlsContext::Role::Client, nullptr);
|
||||||
|
int fd = static_cast<int>(client_fd_native);
|
||||||
|
if (!tls.handshake(fd, client_error)) { close_sock(client_fd_native); return; }
|
||||||
|
client_ok.store(true);
|
||||||
|
tls_read_exact(tls, reinterpret_cast<uint8_t*>(client_recv), kMsg1Len);
|
||||||
|
tls_write_all(tls, reinterpret_cast<const uint8_t*>(kMsg2), kMsg2Len);
|
||||||
|
close_sock(client_fd_native);
|
||||||
|
});
|
||||||
|
|
||||||
|
server_thr.join();
|
||||||
|
client_thr.join();
|
||||||
|
|
||||||
|
if (!server_error.empty()) std::printf("server TLS error: %s\n", server_error.c_str());
|
||||||
|
if (!client_error.empty()) std::printf("client TLS error: %s\n", client_error.c_str());
|
||||||
|
|
||||||
|
CHECK(server_ok.load());
|
||||||
|
CHECK(client_ok.load());
|
||||||
|
CHECK(std::memcmp(client_recv, kMsg1, kMsg1Len) == 0);
|
||||||
|
CHECK(std::memcmp(server_recv, kMsg2, kMsg2Len) == 0);
|
||||||
|
|
||||||
|
// Verify ServerIdentity round-trip
|
||||||
|
{
|
||||||
|
ServerIdentity id2 = ServerIdentity::generate();
|
||||||
|
CHECK(id2.pk != identity.pk); // different key
|
||||||
|
// Fingerprint is SHA-256 of pk — non-zero
|
||||||
|
bool nonzero = false;
|
||||||
|
for (auto b : id2.fingerprint) if (b) { nonzero = true; break; }
|
||||||
|
CHECK(nonzero);
|
||||||
|
// fingerprint_hex should be 32 colons + 64 hex chars = 95 chars (AA:BB:...)
|
||||||
|
std::string hex = id2.fingerprint_hex();
|
||||||
|
CHECK(hex.size() == 95);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
WSACleanup();
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (g_failures == 0) {
|
||||||
|
std::printf("tls_loopback: all checks passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("tls_loopback: %d failure(s)\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
11
tools/voicecat-admin/CMakeLists.txt
Normal file
11
tools/voicecat-admin/CMakeLists.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
file(GLOB_RECURSE VOICECAT_ADMIN_SOURCES CONFIGURE_DEPENDS
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
|
||||||
|
|
||||||
|
add_executable(voicecat-admin ${VOICECAT_ADMIN_SOURCES})
|
||||||
|
target_compile_features(voicecat-admin PRIVATE cxx_std_20)
|
||||||
|
target_include_directories(voicecat-admin PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||||
|
|
||||||
|
# Links voicecat::server which transitively brings in voicecat::voicecat,
|
||||||
|
# sodium, sqlite3, mbedtls, and all server headers.
|
||||||
|
target_link_libraries(voicecat-admin PRIVATE voicecat::server)
|
||||||
152
tools/voicecat-admin/src/main.cpp
Normal file
152
tools/voicecat-admin/src/main.cpp
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* voicecat-admin — offline account management tool.
|
||||||
|
*
|
||||||
|
* Directly manipulates {data_dir}/voicecat.db without a running server.
|
||||||
|
* Usage:
|
||||||
|
* voicecat-admin [--data-dir <path>] account add <username> [--admin] [--password <p>]
|
||||||
|
* voicecat-admin [--data-dir <path>] account reset <username> [--password <p>]
|
||||||
|
* voicecat-admin [--data-dir <path>] account del <username>
|
||||||
|
* voicecat-admin [--data-dir <path>] account list
|
||||||
|
*/
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#ifdef VOICECAT_HAS_NET
|
||||||
|
|
||||||
|
#include "db.h"
|
||||||
|
using namespace voicecat::server;
|
||||||
|
|
||||||
|
static void print_usage(const char* argv0) {
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"Usage:\n"
|
||||||
|
" %s [--data-dir <path>] account add <user> [--admin] [--password <p>]\n"
|
||||||
|
" %s [--data-dir <path>] account reset <user> [--password <p>]\n"
|
||||||
|
" %s [--data-dir <path>] account del <user>\n"
|
||||||
|
" %s [--data-dir <path>] account list\n",
|
||||||
|
argv0, argv0, argv0, argv0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string read_password_stdin(const char* prompt) {
|
||||||
|
std::fprintf(stderr, "%s: ", prompt);
|
||||||
|
std::fflush(stderr);
|
||||||
|
std::string pw;
|
||||||
|
std::getline(std::cin, pw);
|
||||||
|
return pw;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
std::string data_dir = "voicecat-data";
|
||||||
|
int i = 1;
|
||||||
|
|
||||||
|
// Parse --data-dir
|
||||||
|
if (i < argc && std::strcmp(argv[i], "--data-dir") == 0) {
|
||||||
|
if (++i >= argc) { std::fprintf(stderr, "Missing argument to --data-dir\n"); return 1; }
|
||||||
|
data_dir = argv[i++];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= argc || std::strcmp(argv[i], "account") != 0) {
|
||||||
|
print_usage(argv[0]); return 1;
|
||||||
|
}
|
||||||
|
++i; // skip "account"
|
||||||
|
|
||||||
|
if (i >= argc) { print_usage(argv[0]); return 1; }
|
||||||
|
std::string subcmd = argv[i++];
|
||||||
|
|
||||||
|
std::string db_path = data_dir + "/voicecat.db";
|
||||||
|
Database db(db_path);
|
||||||
|
std::string error;
|
||||||
|
if (!db.open(error)) {
|
||||||
|
std::fprintf(stderr, "Failed to open database %s: %s\n", db_path.c_str(), error.c_str());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcmd == "list") {
|
||||||
|
auto accounts = db.list_accounts();
|
||||||
|
if (accounts.empty()) {
|
||||||
|
std::printf("(no accounts)\n");
|
||||||
|
} else {
|
||||||
|
std::printf("%-20s %-5s %s\n", "username", "admin", "created_at");
|
||||||
|
std::printf("%-20s %-5s %s\n", "--------", "-----", "----------");
|
||||||
|
for (auto& acc : accounts) {
|
||||||
|
std::printf("%-20s %-5s %lld\n",
|
||||||
|
acc.username.c_str(),
|
||||||
|
acc.is_admin ? "yes" : "no",
|
||||||
|
static_cast<long long>(acc.created_at));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcmd == "add") {
|
||||||
|
if (i >= argc) { std::fprintf(stderr, "add requires a username\n"); return 1; }
|
||||||
|
std::string username = argv[i++];
|
||||||
|
bool is_admin = false;
|
||||||
|
std::optional<std::string> password;
|
||||||
|
|
||||||
|
while (i < argc) {
|
||||||
|
if (std::strcmp(argv[i], "--admin") == 0) { is_admin = true; ++i; }
|
||||||
|
else if (std::strcmp(argv[i], "--password") == 0) {
|
||||||
|
if (++i >= argc) { std::fprintf(stderr, "Missing argument to --password\n"); return 1; }
|
||||||
|
password = argv[i++];
|
||||||
|
} else { std::fprintf(stderr, "Unknown option: %s\n", argv[i]); return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) password = read_password_stdin("Password");
|
||||||
|
if (password->empty()) { std::fprintf(stderr, "Password must not be empty\n"); return 1; }
|
||||||
|
|
||||||
|
auto acc = db.create_account(username, *password, is_admin, error);
|
||||||
|
if (!acc) { std::fprintf(stderr, "Failed to create account: %s\n", error.c_str()); return 1; }
|
||||||
|
std::printf("Created account '%s'%s (id=%lld)\n",
|
||||||
|
acc->username.c_str(), is_admin ? " [admin]" : "",
|
||||||
|
static_cast<long long>(acc->id));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcmd == "reset") {
|
||||||
|
if (i >= argc) { std::fprintf(stderr, "reset requires a username\n"); return 1; }
|
||||||
|
std::string username = argv[i++];
|
||||||
|
std::optional<std::string> password;
|
||||||
|
|
||||||
|
while (i < argc) {
|
||||||
|
if (std::strcmp(argv[i], "--password") == 0) {
|
||||||
|
if (++i >= argc) { std::fprintf(stderr, "Missing argument to --password\n"); return 1; }
|
||||||
|
password = argv[i++];
|
||||||
|
} else { std::fprintf(stderr, "Unknown option: %s\n", argv[i]); return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) password = read_password_stdin("New password");
|
||||||
|
if (password->empty()) { std::fprintf(stderr, "Password must not be empty\n"); return 1; }
|
||||||
|
|
||||||
|
if (!db.reset_password(username, *password, error)) {
|
||||||
|
std::fprintf(stderr, "Failed: %s\n", error.c_str()); return 1;
|
||||||
|
}
|
||||||
|
std::printf("Password reset for '%s'\n", username.c_str());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcmd == "del") {
|
||||||
|
if (i >= argc) { std::fprintf(stderr, "del requires a username\n"); return 1; }
|
||||||
|
std::string username = argv[i++];
|
||||||
|
if (!db.delete_account(username, error)) {
|
||||||
|
std::fprintf(stderr, "Failed: %s\n", error.c_str()); return 1;
|
||||||
|
}
|
||||||
|
std::printf("Deleted account '%s'\n", username.c_str());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fprintf(stderr, "Unknown subcommand: %s\n", subcmd.c_str());
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else // !VOICECAT_HAS_NET
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
std::fprintf(stderr, "voicecat-admin requires VOICECAT_HAS_NET (build with m1-dev preset)\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // VOICECAT_HAS_NET
|
||||||
21
vcpkg.json
21
vcpkg.json
@@ -3,19 +3,16 @@
|
|||||||
"name": "voicecat",
|
"name": "voicecat",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "Self-hosted native voice & text chat. See docs/.",
|
"description": "Self-hosted native voice & text chat. See docs/.",
|
||||||
"builtin-baseline": "0000000000000000000000000000000000000000",
|
"builtin-baseline": "d46283cf33cf5de7bd88e12156ce03882be1f179",
|
||||||
"$comment-baseline": "TODO: set builtin-baseline to a real vcpkg commit SHA when first enabling VOICECAT_USE_VCPKG_DEPS. Until then the skeleton builds with deps OFF and this manifest is inert.",
|
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{ "name": "opus", "$why": "voice codec (libopus 1.6) — docs/voice.md" },
|
{ "name": "protobuf", "$why": "control-plane serialization — docs/protocol.md" },
|
||||||
{ "name": "libsodium", "$why": "Argon2id, ChaCha20-Poly1305 media AEAD, Ed25519 — docs/security.md" },
|
{ "name": "libsodium", "$why": "Argon2id, ChaCha20-Poly1305 media AEAD, Ed25519 — docs/security.md" },
|
||||||
{ "name": "mbedtls", "$why": "TLS 1.3 control channel + keying-material exporter — docs/security.md" },
|
{ "name": "mbedtls", "$why": "TLS 1.3 control channel + keying-material exporter — docs/security.md" },
|
||||||
{ "name": "protobuf", "$why": "control-plane serialization — docs/protocol.md" },
|
{ "name": "asio", "$why": "TCP/UDP/timers reactor — docs/architecture.md" },
|
||||||
{ "name": "sqlite3", "$why": "server accounts/state — docs/security.md" },
|
{ "name": "sqlite3", "$why": "server accounts/state — docs/security.md" },
|
||||||
{ "name": "asio", "$why": "TCP/UDP/timers reactor — docs/architecture.md" },
|
{ "name": "spdlog", "$why": "logging — docs/tech-stack.md" },
|
||||||
{ "name": "miniaudio", "$why": "cross-platform capture/playback — docs/tech-stack.md" },
|
{ "name": "opus", "$why": "voice codec (libopus) — docs/voice.md — M2" },
|
||||||
{ "name": "speexdsp", "$why": "resampling + jitter reference — docs/tech-stack.md" },
|
{ "name": "miniaudio", "$why": "cross-platform capture/playback — docs/tech-stack.md — M2" }
|
||||||
{ "name": "webrtc-audio-processing", "$why": "AEC/NS/AGC/VAD (APM) — docs/voice.md" },
|
|
||||||
{ "name": "spdlog", "$why": "logging — docs/tech-stack.md" }
|
|
||||||
],
|
],
|
||||||
"$license-note": "All of the above are permissive (BSD/MIT/ISC/Apache-2.0/public-domain). No GPL/LGPL — see docs/tech-stack.md §5."
|
"$license-note": "All of the above are permissive (BSD/MIT/ISC/Apache-2.0/public-domain). No GPL/LGPL — see docs/tech-stack.md §5."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user