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>
2026-06-15 23:48:44 +02:00
|
|
|
#include "conn_session.h"
|
|
|
|
|
|
|
|
|
|
#ifdef VOICECAT_HAS_NET
|
|
|
|
|
|
feat(M3): multi-stream & per-channel tuning
Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC +
SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise-
reduction, talk indicators, and enforced per-channel Opus configurability
(mono/stereo, bitrate, frame size, FEC/DTX, application).
Bugs fixed along the way (found while implementing, not pre-existing scope):
- Server hard-coded stream_id=1 for every announce, so a second stream from
the same user silently overwrote the first in SessionRegistry::set_user_stream.
Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop
validates against announced_stream_ids_ before clearing.
- Client dropped mode/dtx/complexity/application from effective_audio even for
the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever
applied to OpusParams. Fixed on both the send (handle_stream_announce_result)
and receive (sync_remote_streams) paths via a shared
opus_params_from_audio_config() helper.
- OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application
and wired it through.
- on_playback's per-stream decode passed the wrong frame_size to opus_decode
(total samples instead of samples-per-channel), which would have overflowed
the decode buffer for any stereo stream.
- teardown_voice() raced when called concurrently from run_io()'s own cleanup
and from disconnect() on a different thread -- both could see
udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the
same std::thread (intermittent std::system_error under ctest). Fixed with a
teardown_mu_ guard instead of carrying the flake forward.
New:
- Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/
FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX);
handle_stream_announce enforces the channel's config, clamping (not
overriding) bitrate_bps to its ceiling.
- core/src/core/client.h/.cpp: local-stream state is now a
std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with
request_id-correlated announce/result handling (request_id already
round-tripped on the wire; just wasn't read before). on_capture_frame is
kind-aware and upmixes mono capture to stereo when a stream's config calls
for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired
through set_remote_stream. New run_talk_timer() thread emits
VC_EVENT_TALK_STATE from both remote and local edge detection.
- core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps
(inject_capture), stereo-to-mono downmix at the decode/mix boundary,
RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled
and last_voice_ms/talking; new set_stream_noise_reduction() and
poll_talk_transitions().
- core/src/session/session.h/.cpp: Stream now carries the full AudioConfig,
not just sample_rate/frame_ms.
- New additive C ABI (core/include/voicecat.h): vc_audio_config +
vc_get_stream_audio_config (effective Opus config for any stream you own or
a peer's); vc_test_inject_capture (test-only synthetic PCM injection,
clearly marked, mirrors AudioEngine::inject_capture).
- tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI
(mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two
concurrent local streams, independent gain/mute/NS control, per-channel
config divergence via vc_get_stream_audio_config, talk indicators.
Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently
dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback
capture for SCREEN_AUDIO (synthetic injection only); true stereo playback
output (AudioEngine's mixer/output device stays mono -- Opus itself is fully
stereo-correct on the wire).
ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive
full-suite runs plus 8 standalone runs of the new test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
|
|
|
#include <algorithm>
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
#include <chrono>
|
|
|
|
|
#include <cstdio>
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
#include <cstring>
|
|
|
|
|
|
|
|
|
|
#include <sodium.h>
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
|
|
|
|
|
#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;
|
|
|
|
|
}
|
|
|
|
|
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
static voicecat::v1::Permissions all_permissions() {
|
|
|
|
|
voicecat::v1::Permissions p;
|
|
|
|
|
p.set_can_create_temp_channel(true);
|
|
|
|
|
p.set_can_kick(true);
|
|
|
|
|
p.set_can_ban(true);
|
|
|
|
|
p.set_can_move_users(true);
|
|
|
|
|
p.set_can_admin_accounts(true);
|
|
|
|
|
p.set_is_admin(true);
|
|
|
|
|
return p;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static voicecat::v1::Permissions no_permissions() {
|
|
|
|
|
voicecat::v1::Permissions p;
|
|
|
|
|
return p;
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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,
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
bool allow_guests,
|
|
|
|
|
uint16_t udp_media_port)
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
: db_(std::move(db)),
|
|
|
|
|
registry_(std::move(registry)),
|
|
|
|
|
workers_(std::move(workers)),
|
|
|
|
|
server_fp_(server_fp),
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
allow_guests_(allow_guests),
|
|
|
|
|
udp_media_port_(udp_media_port) {
|
|
|
|
|
randombytes_buf(udp_token_.data(), udp_token_.size());
|
|
|
|
|
}
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
|
|
|
|
|
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)
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
handle_leave_channel();
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
break;
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
case voicecat::v1::Envelope::kUdpBinding:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_udp_binding(env.request_id(), env.udp_binding());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kStreamAnnounce:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_stream_announce(env.request_id(), env.stream_announce());
|
|
|
|
|
break;
|
2026-06-16 02:12:50 +02:00
|
|
|
case voicecat::v1::Envelope::kStreamStop:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_stream_stop(env.stream_stop());
|
|
|
|
|
break;
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
|
|
|
|
|
// ── M5 moderation / admin ─────────────────────────────────────────────
|
|
|
|
|
case voicecat::v1::Envelope::kKick:
|
|
|
|
|
if (st == State::Authenticated) handle_kick_request(env.request_id(), env.kick());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kBan:
|
|
|
|
|
if (st == State::Authenticated) handle_ban_request(env.request_id(), env.ban());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kSetPermission:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_set_permission(env.request_id(), env.set_permission());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kServerMute:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_server_mute_request(env.request_id(), env.server_mute());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kMoveUser:
|
|
|
|
|
if (st == State::Authenticated) handle_move_user(env.request_id(), env.move_user());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kCreateChannel:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_create_channel(env.request_id(), env.create_channel());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kEditChannel:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_edit_channel(env.request_id(), env.edit_channel());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kDeleteChannel:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_delete_channel(env.request_id(), env.delete_channel());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kCreateAccount:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_create_account(env.request_id(), env.create_account());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kResetPassword:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_reset_password(env.request_id(), env.reset_password());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kDeleteAccount:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_delete_account(env.request_id(), env.delete_account());
|
|
|
|
|
break;
|
|
|
|
|
case voicecat::v1::Envelope::kListAccounts:
|
|
|
|
|
if (st == State::Authenticated)
|
|
|
|
|
handle_list_accounts(env.request_id(), env.list_accounts());
|
|
|
|
|
break;
|
|
|
|
|
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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_();
|
|
|
|
|
}
|
|
|
|
|
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
// ── M2: media crypto ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
void ConnSession::set_media_crypto(
|
|
|
|
|
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send,
|
|
|
|
|
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv) {
|
|
|
|
|
std::lock_guard lk(crypto_mu_);
|
|
|
|
|
send_crypto_ = std::move(send);
|
|
|
|
|
recv_crypto_ = std::move(recv);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
voicecat::crypto::SodiumMediaCrypto* ConnSession::send_crypto() {
|
|
|
|
|
std::lock_guard lk(crypto_mu_);
|
|
|
|
|
return send_crypto_.get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
voicecat::crypto::SodiumMediaCrypto* ConnSession::recv_crypto() {
|
|
|
|
|
std::lock_guard lk(crypto_mu_);
|
|
|
|
|
return recv_crypto_.get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── M2: UDP endpoint ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
void ConnSession::set_udp_endpoint(asio::ip::udp::endpoint ep) {
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(udp_ep_mu_);
|
|
|
|
|
udp_ep_ = ep;
|
|
|
|
|
}
|
|
|
|
|
has_udp_ep_.store(true, std::memory_order_release);
|
|
|
|
|
registry_->register_udp_endpoint(ep, session_id_);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
asio::ip::udp::endpoint ConnSession::udp_endpoint() const {
|
|
|
|
|
std::lock_guard lk(udp_ep_mu_);
|
|
|
|
|
return udp_ep_;
|
|
|
|
|
}
|
|
|
|
|
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
// ── Permission helpers ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
bool ConnSession::has_permission(bool (voicecat::v1::Permissions::* getter)() const) const {
|
|
|
|
|
return (permissions_.*getter)();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::set_permissions(const voicecat::v1::Permissions& perms) {
|
|
|
|
|
permissions_ = perms;
|
|
|
|
|
registry_->set_session_permissions(session_id_, perms);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::send_generic_result(uint64_t req_id, bool ok, uint32_t code,
|
|
|
|
|
const std::string& message) {
|
|
|
|
|
auto env = make_env(req_id);
|
|
|
|
|
auto* gr = env.mutable_generic_result();
|
|
|
|
|
gr->set_ok(ok);
|
|
|
|
|
gr->set_code(code);
|
|
|
|
|
gr->set_message(message);
|
|
|
|
|
send_envelope(env);
|
|
|
|
|
}
|
|
|
|
|
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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());
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
if (udp_media_port_) hello->set_udp_port(udp_media_port_);
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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;
|
|
|
|
|
}
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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);
|
|
|
|
|
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
permissions_ = no_permissions();
|
|
|
|
|
registry_->set_session_permissions(session_id_, permissions_);
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
registry_->register_udp_token(udp_token_, session_id_);
|
|
|
|
|
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
{
|
|
|
|
|
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;
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
*res->mutable_permissions() = permissions_;
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
res->set_udp_token(udp_token_.data(), udp_token_.size());
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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] {
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
// M5: check username bans before verifying password.
|
|
|
|
|
if (self->db_->ban_check("username", username)) {
|
|
|
|
|
auto env = make_env(req_id);
|
|
|
|
|
env.mutable_auth_result()->set_ok(false);
|
|
|
|
|
env.mutable_auth_result()->set_error("account banned");
|
|
|
|
|
self->send_envelope(env);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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);
|
|
|
|
|
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
voicecat::v1::Permissions perms = acc->is_admin ? all_permissions() : no_permissions();
|
|
|
|
|
self->permissions_ = perms;
|
|
|
|
|
self->registry_->set_session_permissions(self->session_id_, perms);
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
self->registry_->register_udp_token(self->udp_token_, self->session_id_);
|
|
|
|
|
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
{
|
|
|
|
|
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;
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
*res->mutable_permissions() = perms;
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
res->set_udp_token(self->udp_token_.data(), self->udp_token_.size());
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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) {
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
uint32_t uid = user_id_.load();
|
|
|
|
|
auto ch = registry_->get_channel(msg.channel_id());
|
|
|
|
|
if (!ch) {
|
|
|
|
|
auto env = make_env(req_id);
|
|
|
|
|
auto* res = env.mutable_join_channel_result();
|
|
|
|
|
res->set_ok(false);
|
|
|
|
|
res->set_error("channel not found");
|
|
|
|
|
send_envelope(env);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ch->password_protected()) {
|
|
|
|
|
if (!registry_->check_channel_password(msg.channel_id(), msg.password())) {
|
|
|
|
|
auto env = make_env(req_id);
|
|
|
|
|
auto* res = env.mutable_join_channel_result();
|
|
|
|
|
res->set_ok(false);
|
|
|
|
|
res->set_error("invalid channel password");
|
|
|
|
|
send_envelope(env);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ch->max_users() > 0) {
|
|
|
|
|
auto members = registry_->find_channel_sessions(msg.channel_id(), session_id_);
|
|
|
|
|
if (members.size() >= ch->max_users()) {
|
|
|
|
|
auto env = make_env(req_id);
|
|
|
|
|
auto* res = env.mutable_join_channel_result();
|
|
|
|
|
res->set_ok(false);
|
|
|
|
|
res->set_error("channel is full");
|
|
|
|
|
send_envelope(env);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool ok = registry_->set_user_channel(uid, msg.channel_id());
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
auto env = make_env(req_id);
|
|
|
|
|
auto* res = env.mutable_join_channel_result();
|
|
|
|
|
res->set_ok(ok);
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
if (!ok) {
|
|
|
|
|
res->set_error("channel not found");
|
|
|
|
|
} else {
|
|
|
|
|
res->set_channel_id(msg.channel_id());
|
|
|
|
|
*res->mutable_audio() = ch->audio();
|
|
|
|
|
|
2026-06-17 20:48:50 +02:00
|
|
|
// Broadcast that this user changed channel — to everyone INCLUDING the mover.
|
|
|
|
|
// The JoinChannelResult only acks the request; this UserEvent is the authoritative
|
|
|
|
|
// state change every client (mover included) applies to its local model. Excluding
|
|
|
|
|
// the mover here is what made it read a stale self-channel. See docs/protocol.md §6.
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
if (auto updated_user = registry_->user_snapshot_user(uid)) {
|
|
|
|
|
auto bcast = make_env();
|
|
|
|
|
auto* ue = bcast.mutable_user_event();
|
|
|
|
|
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
|
|
|
|
*ue->mutable_user() = *updated_user;
|
2026-06-17 20:48:50 +02:00
|
|
|
registry_->broadcast(bcast, /*exclude*/ 0);
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
}
|
|
|
|
|
}
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
send_envelope(env);
|
|
|
|
|
}
|
|
|
|
|
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
void ConnSession::handle_leave_channel() {
|
|
|
|
|
uint32_t uid = user_id_.load();
|
|
|
|
|
if (!uid) return;
|
|
|
|
|
if (!registry_->set_user_channel(uid, 1)) return;
|
|
|
|
|
if (auto updated_user = registry_->user_snapshot_user(uid)) {
|
|
|
|
|
auto bcast = make_env();
|
|
|
|
|
auto* ue = bcast.mutable_user_event();
|
|
|
|
|
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
|
|
|
|
*ue->mutable_user() = *updated_user;
|
2026-06-17 20:48:50 +02:00
|
|
|
registry_->broadcast(bcast, /*exclude*/ 0); // include the leaver — same as join
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
void ConnSession::handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBinding& msg) {
|
|
|
|
|
if (msg.ack()) return; // server→client direction; ignore if echoed back
|
|
|
|
|
|
|
|
|
|
const std::string& tok = msg.udp_token();
|
|
|
|
|
if (tok.size() != 16 || std::memcmp(tok.data(), udp_token_.data(), 16) != 0) {
|
|
|
|
|
// Bad token — silently ignore (don't leak timing information)
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ack over TCP; MediaRelay will set the UDP endpoint when the UDP binding packet arrives.
|
|
|
|
|
auto env = make_env(req_id);
|
|
|
|
|
env.mutable_udp_binding()->set_ack(true);
|
|
|
|
|
send_envelope(env);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_stream_announce(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::StreamAnnounce& msg) {
|
|
|
|
|
uint32_t ssrc = registry_->assign_ssrc(session_id_);
|
feat(M3): multi-stream & per-channel tuning
Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC +
SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise-
reduction, talk indicators, and enforced per-channel Opus configurability
(mono/stereo, bitrate, frame size, FEC/DTX, application).
Bugs fixed along the way (found while implementing, not pre-existing scope):
- Server hard-coded stream_id=1 for every announce, so a second stream from
the same user silently overwrote the first in SessionRegistry::set_user_stream.
Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop
validates against announced_stream_ids_ before clearing.
- Client dropped mode/dtx/complexity/application from effective_audio even for
the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever
applied to OpusParams. Fixed on both the send (handle_stream_announce_result)
and receive (sync_remote_streams) paths via a shared
opus_params_from_audio_config() helper.
- OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application
and wired it through.
- on_playback's per-stream decode passed the wrong frame_size to opus_decode
(total samples instead of samples-per-channel), which would have overflowed
the decode buffer for any stereo stream.
- teardown_voice() raced when called concurrently from run_io()'s own cleanup
and from disconnect() on a different thread -- both could see
udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the
same std::thread (intermittent std::system_error under ctest). Fixed with a
teardown_mu_ guard instead of carrying the flake forward.
New:
- Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/
FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX);
handle_stream_announce enforces the channel's config, clamping (not
overriding) bitrate_bps to its ceiling.
- core/src/core/client.h/.cpp: local-stream state is now a
std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with
request_id-correlated announce/result handling (request_id already
round-tripped on the wire; just wasn't read before). on_capture_frame is
kind-aware and upmixes mono capture to stereo when a stream's config calls
for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired
through set_remote_stream. New run_talk_timer() thread emits
VC_EVENT_TALK_STATE from both remote and local edge detection.
- core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps
(inject_capture), stereo-to-mono downmix at the decode/mix boundary,
RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled
and last_voice_ms/talking; new set_stream_noise_reduction() and
poll_talk_transitions().
- core/src/session/session.h/.cpp: Stream now carries the full AudioConfig,
not just sample_rate/frame_ms.
- New additive C ABI (core/include/voicecat.h): vc_audio_config +
vc_get_stream_audio_config (effective Opus config for any stream you own or
a peer's); vc_test_inject_capture (test-only synthetic PCM injection,
clearly marked, mirrors AudioEngine::inject_capture).
- tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI
(mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two
concurrent local streams, independent gain/mute/NS control, per-channel
config divergence via vc_get_stream_audio_config, talk indicators.
Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently
dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback
capture for SCREEN_AUDIO (synthetic injection only); true stereo playback
output (AudioEngine's mixer/output device stays mono -- Opus itself is fully
stereo-correct on the wire).
ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive
full-suite runs plus 8 standalone runs of the new test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
|
|
|
uint32_t stream_id = next_stream_id_++;
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
|
|
|
|
|
auto env = make_env(req_id);
|
|
|
|
|
auto* res = env.mutable_stream_announce_result();
|
|
|
|
|
res->set_ok(true);
|
feat(M3): multi-stream & per-channel tuning
Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC +
SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise-
reduction, talk indicators, and enforced per-channel Opus configurability
(mono/stereo, bitrate, frame size, FEC/DTX, application).
Bugs fixed along the way (found while implementing, not pre-existing scope):
- Server hard-coded stream_id=1 for every announce, so a second stream from
the same user silently overwrote the first in SessionRegistry::set_user_stream.
Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop
validates against announced_stream_ids_ before clearing.
- Client dropped mode/dtx/complexity/application from effective_audio even for
the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever
applied to OpusParams. Fixed on both the send (handle_stream_announce_result)
and receive (sync_remote_streams) paths via a shared
opus_params_from_audio_config() helper.
- OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application
and wired it through.
- on_playback's per-stream decode passed the wrong frame_size to opus_decode
(total samples instead of samples-per-channel), which would have overflowed
the decode buffer for any stereo stream.
- teardown_voice() raced when called concurrently from run_io()'s own cleanup
and from disconnect() on a different thread -- both could see
udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the
same std::thread (intermittent std::system_error under ctest). Fixed with a
teardown_mu_ guard instead of carrying the flake forward.
New:
- Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/
FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX);
handle_stream_announce enforces the channel's config, clamping (not
overriding) bitrate_bps to its ceiling.
- core/src/core/client.h/.cpp: local-stream state is now a
std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with
request_id-correlated announce/result handling (request_id already
round-tripped on the wire; just wasn't read before). on_capture_frame is
kind-aware and upmixes mono capture to stereo when a stream's config calls
for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired
through set_remote_stream. New run_talk_timer() thread emits
VC_EVENT_TALK_STATE from both remote and local edge detection.
- core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps
(inject_capture), stereo-to-mono downmix at the decode/mix boundary,
RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled
and last_voice_ms/talking; new set_stream_noise_reduction() and
poll_talk_transitions().
- core/src/session/session.h/.cpp: Stream now carries the full AudioConfig,
not just sample_rate/frame_ms.
- New additive C ABI (core/include/voicecat.h): vc_audio_config +
vc_get_stream_audio_config (effective Opus config for any stream you own or
a peer's); vc_test_inject_capture (test-only synthetic PCM injection,
clearly marked, mirrors AudioEngine::inject_capture).
- tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI
(mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two
concurrent local streams, independent gain/mute/NS control, per-channel
config divergence via vc_get_stream_audio_config, talk indicators.
Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently
dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback
capture for SCREEN_AUDIO (synthetic injection only); true stereo playback
output (AudioEngine's mixer/output device stays mono -- Opus itself is fully
stereo-correct on the wire).
ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive
full-suite runs plus 8 standalone runs of the new test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
|
|
|
res->set_stream_id(stream_id);
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
res->set_ssrc(ssrc);
|
|
|
|
|
|
feat(M3): multi-stream & per-channel tuning
Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC +
SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise-
reduction, talk indicators, and enforced per-channel Opus configurability
(mono/stereo, bitrate, frame size, FEC/DTX, application).
Bugs fixed along the way (found while implementing, not pre-existing scope):
- Server hard-coded stream_id=1 for every announce, so a second stream from
the same user silently overwrote the first in SessionRegistry::set_user_stream.
Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop
validates against announced_stream_ids_ before clearing.
- Client dropped mode/dtx/complexity/application from effective_audio even for
the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever
applied to OpusParams. Fixed on both the send (handle_stream_announce_result)
and receive (sync_remote_streams) paths via a shared
opus_params_from_audio_config() helper.
- OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application
and wired it through.
- on_playback's per-stream decode passed the wrong frame_size to opus_decode
(total samples instead of samples-per-channel), which would have overflowed
the decode buffer for any stereo stream.
- teardown_voice() raced when called concurrently from run_io()'s own cleanup
and from disconnect() on a different thread -- both could see
udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the
same std::thread (intermittent std::system_error under ctest). Fixed with a
teardown_mu_ guard instead of carrying the flake forward.
New:
- Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/
FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX);
handle_stream_announce enforces the channel's config, clamping (not
overriding) bitrate_bps to its ceiling.
- core/src/core/client.h/.cpp: local-stream state is now a
std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with
request_id-correlated announce/result handling (request_id already
round-tripped on the wire; just wasn't read before). on_capture_frame is
kind-aware and upmixes mono capture to stereo when a stream's config calls
for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired
through set_remote_stream. New run_talk_timer() thread emits
VC_EVENT_TALK_STATE from both remote and local edge detection.
- core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps
(inject_capture), stereo-to-mono downmix at the decode/mix boundary,
RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled
and last_voice_ms/talking; new set_stream_noise_reduction() and
poll_talk_transitions().
- core/src/session/session.h/.cpp: Stream now carries the full AudioConfig,
not just sample_rate/frame_ms.
- New additive C ABI (core/include/voicecat.h): vc_audio_config +
vc_get_stream_audio_config (effective Opus config for any stream you own or
a peer's); vc_test_inject_capture (test-only synthetic PCM injection,
clearly marked, mirrors AudioEngine::inject_capture).
- tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI
(mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two
concurrent local streams, independent gain/mute/NS control, per-channel
config divergence via vc_get_stream_audio_config, talk indicators.
Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently
dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback
capture for SCREEN_AUDIO (synthetic injection only); true stereo playback
output (AudioEngine's mixer/output device stays mono -- Opus itself is fully
stereo-correct on the wire).
ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive
full-suite runs plus 8 standalone runs of the new test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
|
|
|
// Per-channel AudioConfig is authoritative (docs/voice.md §3): the channel's mode/
|
|
|
|
|
// frame_ms/application/fec/dtx/complexity/expected_packet_loss apply to every stream
|
|
|
|
|
// announced into it, regardless of kind. bitrate_bps is clamped (not overridden) to the
|
|
|
|
|
// channel's ceiling so a client may still request less. sample_rate stays
|
|
|
|
|
// client-requested-or-48000 — everything runs at 48kHz internally per voice.md §3.
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
auto* eff = res->mutable_effective_audio();
|
feat(M3): multi-stream & per-channel tuning
Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC +
SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise-
reduction, talk indicators, and enforced per-channel Opus configurability
(mono/stereo, bitrate, frame size, FEC/DTX, application).
Bugs fixed along the way (found while implementing, not pre-existing scope):
- Server hard-coded stream_id=1 for every announce, so a second stream from
the same user silently overwrote the first in SessionRegistry::set_user_stream.
Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop
validates against announced_stream_ids_ before clearing.
- Client dropped mode/dtx/complexity/application from effective_audio even for
the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever
applied to OpusParams. Fixed on both the send (handle_stream_announce_result)
and receive (sync_remote_streams) paths via a shared
opus_params_from_audio_config() helper.
- OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application
and wired it through.
- on_playback's per-stream decode passed the wrong frame_size to opus_decode
(total samples instead of samples-per-channel), which would have overflowed
the decode buffer for any stereo stream.
- teardown_voice() raced when called concurrently from run_io()'s own cleanup
and from disconnect() on a different thread -- both could see
udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the
same std::thread (intermittent std::system_error under ctest). Fixed with a
teardown_mu_ guard instead of carrying the flake forward.
New:
- Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/
FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX);
handle_stream_announce enforces the channel's config, clamping (not
overriding) bitrate_bps to its ceiling.
- core/src/core/client.h/.cpp: local-stream state is now a
std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with
request_id-correlated announce/result handling (request_id already
round-tripped on the wire; just wasn't read before). on_capture_frame is
kind-aware and upmixes mono capture to stereo when a stream's config calls
for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired
through set_remote_stream. New run_talk_timer() thread emits
VC_EVENT_TALK_STATE from both remote and local edge detection.
- core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps
(inject_capture), stereo-to-mono downmix at the decode/mix boundary,
RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled
and last_voice_ms/talking; new set_stream_noise_reduction() and
poll_talk_transitions().
- core/src/session/session.h/.cpp: Stream now carries the full AudioConfig,
not just sample_rate/frame_ms.
- New additive C ABI (core/include/voicecat.h): vc_audio_config +
vc_get_stream_audio_config (effective Opus config for any stream you own or
a peer's); vc_test_inject_capture (test-only synthetic PCM injection,
clearly marked, mirrors AudioEngine::inject_capture).
- tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI
(mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two
concurrent local streams, independent gain/mute/NS control, per-channel
config divergence via vc_get_stream_audio_config, talk indicators.
Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently
dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback
capture for SCREEN_AUDIO (synthetic injection only); true stereo playback
output (AudioEngine's mixer/output device stays mono -- Opus itself is fully
stereo-correct on the wire).
ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive
full-suite runs plus 8 standalone runs of the new test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
|
|
|
auto chan_cfg = registry_->channel_audio_config(registry_->user_channel(user_id_.load()));
|
|
|
|
|
uint32_t requested_bps =
|
|
|
|
|
msg.has_requested_audio() ? msg.requested_audio().bitrate_bps() : 0;
|
|
|
|
|
uint32_t requested_rate =
|
|
|
|
|
msg.has_requested_audio() ? msg.requested_audio().sample_rate() : 0;
|
|
|
|
|
if (chan_cfg) {
|
|
|
|
|
*eff = *chan_cfg;
|
|
|
|
|
eff->set_bitrate_bps(requested_bps > 0 ? std::min(requested_bps, chan_cfg->bitrate_bps())
|
|
|
|
|
: chan_cfg->bitrate_bps());
|
|
|
|
|
eff->set_sample_rate(requested_rate > 0 ? requested_rate : 48000);
|
|
|
|
|
} else if (msg.has_requested_audio()) {
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
*eff = msg.requested_audio();
|
|
|
|
|
} else {
|
|
|
|
|
eff->set_codec(0); // OPUS
|
|
|
|
|
eff->set_sample_rate(48000);
|
|
|
|
|
eff->set_bitrate_bps(24000);
|
|
|
|
|
eff->set_frame_ms(20);
|
|
|
|
|
eff->set_fec(true);
|
|
|
|
|
}
|
|
|
|
|
if (eff->sample_rate() == 0) eff->set_sample_rate(48000);
|
|
|
|
|
if (eff->bitrate_bps() == 0) eff->set_bitrate_bps(24000);
|
|
|
|
|
if (eff->frame_ms() == 0) eff->set_frame_ms(20);
|
|
|
|
|
|
feat(M3): multi-stream & per-channel tuning
Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC +
SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise-
reduction, talk indicators, and enforced per-channel Opus configurability
(mono/stereo, bitrate, frame size, FEC/DTX, application).
Bugs fixed along the way (found while implementing, not pre-existing scope):
- Server hard-coded stream_id=1 for every announce, so a second stream from
the same user silently overwrote the first in SessionRegistry::set_user_stream.
Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop
validates against announced_stream_ids_ before clearing.
- Client dropped mode/dtx/complexity/application from effective_audio even for
the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever
applied to OpusParams. Fixed on both the send (handle_stream_announce_result)
and receive (sync_remote_streams) paths via a shared
opus_params_from_audio_config() helper.
- OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application
and wired it through.
- on_playback's per-stream decode passed the wrong frame_size to opus_decode
(total samples instead of samples-per-channel), which would have overflowed
the decode buffer for any stereo stream.
- teardown_voice() raced when called concurrently from run_io()'s own cleanup
and from disconnect() on a different thread -- both could see
udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the
same std::thread (intermittent std::system_error under ctest). Fixed with a
teardown_mu_ guard instead of carrying the flake forward.
New:
- Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/
FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX);
handle_stream_announce enforces the channel's config, clamping (not
overriding) bitrate_bps to its ceiling.
- core/src/core/client.h/.cpp: local-stream state is now a
std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with
request_id-correlated announce/result handling (request_id already
round-tripped on the wire; just wasn't read before). on_capture_frame is
kind-aware and upmixes mono capture to stereo when a stream's config calls
for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired
through set_remote_stream. New run_talk_timer() thread emits
VC_EVENT_TALK_STATE from both remote and local edge detection.
- core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps
(inject_capture), stereo-to-mono downmix at the decode/mix boundary,
RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled
and last_voice_ms/talking; new set_stream_noise_reduction() and
poll_talk_transitions().
- core/src/session/session.h/.cpp: Stream now carries the full AudioConfig,
not just sample_rate/frame_ms.
- New additive C ABI (core/include/voicecat.h): vc_audio_config +
vc_get_stream_audio_config (effective Opus config for any stream you own or
a peer's); vc_test_inject_capture (test-only synthetic PCM injection,
clearly marked, mirrors AudioEngine::inject_capture).
- tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI
(mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two
concurrent local streams, independent gain/mute/NS control, per-channel
config divergence via vc_get_stream_audio_config, talk indicators.
Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently
dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback
capture for SCREEN_AUDIO (synthetic injection only); true stereo playback
output (AudioEngine's mixer/output device stays mono -- Opus itself is fully
stereo-correct on the wire).
ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive
full-suite runs plus 8 standalone runs of the new test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
|
|
|
announced_stream_ids_.push_back(stream_id);
|
2026-06-16 02:12:50 +02:00
|
|
|
|
|
|
|
|
voicecat::v1::StreamInfo info;
|
feat(M3): multi-stream & per-channel tuning
Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC +
SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise-
reduction, talk indicators, and enforced per-channel Opus configurability
(mono/stereo, bitrate, frame size, FEC/DTX, application).
Bugs fixed along the way (found while implementing, not pre-existing scope):
- Server hard-coded stream_id=1 for every announce, so a second stream from
the same user silently overwrote the first in SessionRegistry::set_user_stream.
Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop
validates against announced_stream_ids_ before clearing.
- Client dropped mode/dtx/complexity/application from effective_audio even for
the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever
applied to OpusParams. Fixed on both the send (handle_stream_announce_result)
and receive (sync_remote_streams) paths via a shared
opus_params_from_audio_config() helper.
- OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application
and wired it through.
- on_playback's per-stream decode passed the wrong frame_size to opus_decode
(total samples instead of samples-per-channel), which would have overflowed
the decode buffer for any stereo stream.
- teardown_voice() raced when called concurrently from run_io()'s own cleanup
and from disconnect() on a different thread -- both could see
udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the
same std::thread (intermittent std::system_error under ctest). Fixed with a
teardown_mu_ guard instead of carrying the flake forward.
New:
- Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/
FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX);
handle_stream_announce enforces the channel's config, clamping (not
overriding) bitrate_bps to its ceiling.
- core/src/core/client.h/.cpp: local-stream state is now a
std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with
request_id-correlated announce/result handling (request_id already
round-tripped on the wire; just wasn't read before). on_capture_frame is
kind-aware and upmixes mono capture to stereo when a stream's config calls
for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired
through set_remote_stream. New run_talk_timer() thread emits
VC_EVENT_TALK_STATE from both remote and local edge detection.
- core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps
(inject_capture), stereo-to-mono downmix at the decode/mix boundary,
RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled
and last_voice_ms/talking; new set_stream_noise_reduction() and
poll_talk_transitions().
- core/src/session/session.h/.cpp: Stream now carries the full AudioConfig,
not just sample_rate/frame_ms.
- New additive C ABI (core/include/voicecat.h): vc_audio_config +
vc_get_stream_audio_config (effective Opus config for any stream you own or
a peer's); vc_test_inject_capture (test-only synthetic PCM injection,
clearly marked, mirrors AudioEngine::inject_capture).
- tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI
(mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two
concurrent local streams, independent gain/mute/NS control, per-channel
config divergence via vc_get_stream_audio_config, talk indicators.
Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently
dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback
capture for SCREEN_AUDIO (synthetic injection only); true stereo playback
output (AudioEngine's mixer/output device stays mono -- Opus itself is fully
stereo-correct on the wire).
ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive
full-suite runs plus 8 standalone runs of the new test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
|
|
|
info.set_stream_id(stream_id);
|
2026-06-16 02:12:50 +02:00
|
|
|
info.set_ssrc(ssrc);
|
|
|
|
|
info.set_kind(msg.kind());
|
|
|
|
|
*info.mutable_audio() = *eff;
|
|
|
|
|
info.set_label(msg.label());
|
|
|
|
|
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
send_envelope(env);
|
2026-06-16 02:12:50 +02:00
|
|
|
|
|
|
|
|
auto updated = registry_->set_user_stream(user_id_.load(), info);
|
|
|
|
|
if (updated) {
|
|
|
|
|
auto bcast = make_env();
|
|
|
|
|
auto* ue = bcast.mutable_user_event();
|
|
|
|
|
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
|
|
|
|
*ue->mutable_user() = *updated;
|
2026-06-17 20:48:50 +02:00
|
|
|
registry_->broadcast(bcast, /*exclude*/ 0); // include announcer; result only acks
|
2026-06-16 02:12:50 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_stream_stop(const voicecat::v1::StreamStop& msg) {
|
feat(M3): multi-stream & per-channel tuning
Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC +
SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise-
reduction, talk indicators, and enforced per-channel Opus configurability
(mono/stereo, bitrate, frame size, FEC/DTX, application).
Bugs fixed along the way (found while implementing, not pre-existing scope):
- Server hard-coded stream_id=1 for every announce, so a second stream from
the same user silently overwrote the first in SessionRegistry::set_user_stream.
Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop
validates against announced_stream_ids_ before clearing.
- Client dropped mode/dtx/complexity/application from effective_audio even for
the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever
applied to OpusParams. Fixed on both the send (handle_stream_announce_result)
and receive (sync_remote_streams) paths via a shared
opus_params_from_audio_config() helper.
- OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application
and wired it through.
- on_playback's per-stream decode passed the wrong frame_size to opus_decode
(total samples instead of samples-per-channel), which would have overflowed
the decode buffer for any stereo stream.
- teardown_voice() raced when called concurrently from run_io()'s own cleanup
and from disconnect() on a different thread -- both could see
udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the
same std::thread (intermittent std::system_error under ctest). Fixed with a
teardown_mu_ guard instead of carrying the flake forward.
New:
- Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/
FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX);
handle_stream_announce enforces the channel's config, clamping (not
overriding) bitrate_bps to its ceiling.
- core/src/core/client.h/.cpp: local-stream state is now a
std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with
request_id-correlated announce/result handling (request_id already
round-tripped on the wire; just wasn't read before). on_capture_frame is
kind-aware and upmixes mono capture to stereo when a stream's config calls
for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired
through set_remote_stream. New run_talk_timer() thread emits
VC_EVENT_TALK_STATE from both remote and local edge detection.
- core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps
(inject_capture), stereo-to-mono downmix at the decode/mix boundary,
RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled
and last_voice_ms/talking; new set_stream_noise_reduction() and
poll_talk_transitions().
- core/src/session/session.h/.cpp: Stream now carries the full AudioConfig,
not just sample_rate/frame_ms.
- New additive C ABI (core/include/voicecat.h): vc_audio_config +
vc_get_stream_audio_config (effective Opus config for any stream you own or
a peer's); vc_test_inject_capture (test-only synthetic PCM injection,
clearly marked, mirrors AudioEngine::inject_capture).
- tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI
(mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two
concurrent local streams, independent gain/mute/NS control, per-channel
config divergence via vc_get_stream_audio_config, talk indicators.
Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently
dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback
capture for SCREEN_AUDIO (synthetic injection only); true stereo playback
output (AudioEngine's mixer/output device stays mono -- Opus itself is fully
stereo-correct on the wire).
ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive
full-suite runs plus 8 standalone runs of the new test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
|
|
|
auto it = std::find(announced_stream_ids_.begin(), announced_stream_ids_.end(),
|
|
|
|
|
msg.stream_id());
|
|
|
|
|
if (it == announced_stream_ids_.end()) return; // not ours — ignore (no spoofed stops)
|
|
|
|
|
announced_stream_ids_.erase(it);
|
|
|
|
|
|
2026-06-16 02:12:50 +02:00
|
|
|
auto updated = registry_->clear_user_stream(user_id_.load(), msg.stream_id());
|
|
|
|
|
if (updated) {
|
|
|
|
|
auto bcast = make_env();
|
|
|
|
|
auto* ue = bcast.mutable_user_event();
|
|
|
|
|
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
|
|
|
|
|
*ue->mutable_user() = *updated;
|
2026-06-17 20:48:50 +02:00
|
|
|
registry_->broadcast(bcast, /*exclude*/ 0); // include the stopper
|
2026-06-16 02:12:50 +02:00
|
|
|
}
|
feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer
Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.
Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:31:14 +02:00
|
|
|
}
|
|
|
|
|
|
M5: moderation, permissions, channel CRUD, in-app account management
- Server-side moderation & permissions (kick/ban/move/server-mute, channel CRUD).
- Database schema v2: channels, bans; BLAKE2b channel passwords, Argon2id accounts.
- C ABI additions and client-side handling (vc_kick_user, vc_ban_user, vc_set_permission, vc_set_server_mute, vc_move_user, vc_create/edit/delete_channel, vc_create/reset/delete/list_account).
- vccli flags for all M5 operations plus --username/--password auth.
- Four new tests covering permissions, kick/ban/move/mute, admin accounts, channel CRUD.
- Docs: protocol.md envelope updates, security.md channel-password hashing, PROGRESS.md.
2026-06-17 15:08:05 +02:00
|
|
|
// ── M5 handlers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
bool ok = registry_->kick_user(msg.user_id(), msg.reason());
|
|
|
|
|
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_ban_request(uint64_t req_id, const voicecat::v1::BanRequest& msg) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_ban)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ban by username for persistence (nickname == username for password users).
|
|
|
|
|
// Also ban by runtime user_id for immediate effect.
|
|
|
|
|
if (auto target = registry_->find_session_by_user_id(msg.user_id())) {
|
|
|
|
|
if (!target->user_id()) {
|
|
|
|
|
send_generic_result(req_id, false, 3, "user not found");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (auto nick = registry_->user_nickname(target->user_id())) {
|
|
|
|
|
std::string err;
|
|
|
|
|
db_->ban_create("username", *nick, msg.reason(),
|
|
|
|
|
static_cast<int64_t>(msg.expires_unix_ms()), err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool ok = registry_->ban_user(msg.user_id(), msg.reason(),
|
|
|
|
|
static_cast<int64_t>(msg.expires_unix_ms()));
|
|
|
|
|
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_set_permission(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::SetPermissionRequest& msg) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
auto target = registry_->find_session_by_user_id(msg.user_id());
|
|
|
|
|
if (!target) {
|
|
|
|
|
send_generic_result(req_id, false, 3, "user not online");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
target->set_permissions(msg.permissions());
|
|
|
|
|
send_generic_result(req_id, true, 0, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_server_mute_request(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::ServerMuteRequest& msg) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
bool ok = registry_->set_server_mute(msg.user_id(), msg.muted(), msg.deafened());
|
|
|
|
|
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_move_user(uint64_t req_id, const voicecat::v1::MoveUserRequest& msg) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_move_users)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
bool ok = registry_->move_user(msg.user_id(), msg.channel_id());
|
|
|
|
|
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : "user or channel not found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_create_channel(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::CreateChannelRequest& msg) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_create_temp_channel)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
std::string error;
|
|
|
|
|
uint32_t id = registry_->create_channel(msg.channel(), msg.password(), error);
|
|
|
|
|
send_generic_result(req_id, id != 0, id != 0 ? 0 : 3,
|
|
|
|
|
id != 0 ? "" : (error.empty() ? "create failed" : error));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_edit_channel(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::EditChannelRequest& msg) {
|
|
|
|
|
if (!is_admin()) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
std::string error;
|
|
|
|
|
bool ok = registry_->update_channel(msg.channel(), msg.password(), error);
|
|
|
|
|
send_generic_result(req_id, ok, ok ? 0 : 3,
|
|
|
|
|
ok ? "" : (error.empty() ? "update failed" : error));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_delete_channel(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::DeleteChannelRequest& msg) {
|
|
|
|
|
if (!is_admin()) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
std::string error;
|
|
|
|
|
bool ok = registry_->delete_channel(msg.channel_id(), error);
|
|
|
|
|
send_generic_result(req_id, ok, ok ? 0 : 3,
|
|
|
|
|
ok ? "" : (error.empty() ? "delete failed" : error));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_create_account(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::CreateAccountRequest& msg) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
auto self = shared_from_this();
|
|
|
|
|
workers_->post([self, req_id, msg]() mutable {
|
|
|
|
|
std::string error;
|
|
|
|
|
auto acc = self->db_->create_account(msg.username(), msg.password(), false, error);
|
|
|
|
|
self->send_generic_result(req_id, acc.has_value(), acc.has_value() ? 0 : 3,
|
|
|
|
|
acc.has_value() ? "" : error);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_reset_password(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::ResetPasswordRequest& msg) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
auto self = shared_from_this();
|
|
|
|
|
workers_->post([self, req_id, msg]() mutable {
|
|
|
|
|
std::string error;
|
|
|
|
|
bool ok = self->db_->reset_password(msg.username(), msg.new_password(), error);
|
|
|
|
|
self->send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : error);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_delete_account(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::DeleteAccountRequest& msg) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
std::string error;
|
|
|
|
|
bool ok = db_->delete_account(msg.username(), error);
|
|
|
|
|
send_generic_result(req_id, ok, ok ? 0 : 3, ok ? "" : error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ConnSession::handle_list_accounts(uint64_t req_id,
|
|
|
|
|
const voicecat::v1::ListAccountsRequest& /*msg*/) {
|
|
|
|
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_admin_accounts)) {
|
|
|
|
|
send_generic_result(req_id, false, 6, "permission denied");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
auto env = make_env(req_id);
|
|
|
|
|
auto* lr = env.mutable_list_accounts_result();
|
|
|
|
|
for (const auto& acc : db_->list_accounts()) {
|
|
|
|
|
auto* e = lr->add_accounts();
|
|
|
|
|
e->set_username(acc.username);
|
|
|
|
|
e->set_is_admin(acc.is_admin);
|
|
|
|
|
e->set_created_at_unix_ms(static_cast<uint64_t>(acc.created_at) * 1000);
|
|
|
|
|
e->set_last_login_unix_ms(static_cast<uint64_t>(acc.last_login) * 1000);
|
|
|
|
|
}
|
|
|
|
|
send_envelope(env);
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-06-15 23:48:44 +02:00
|
|
|
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
|