/* * test_m2_voice — M2 exit criterion. * * Two headless clients authenticate over TLS, perform UDP binding, announce a voice stream, * then Client A sends synthetic Opus frames (440 Hz sine PCM) encrypted with ChaCha20-Poly1305. * The server SFU relay re-encrypts and forwards them to Client B. * * Assertions: * 1. UDP binding and StreamAnnounce succeed for both clients. * 2. Client B receives >= 25 out of 50 sent frames (50% floor accounts for startup latency). * 3. Client B successfully decrypts all frames it receives (auth tag valid). * 4. After simulated loss (every 5th frame skipped), packets_lost counter increases. */ #include #include #ifdef VOICECAT_HAS_NET #include #include #include #include #include #include #include #include #include #ifdef _WIN32 # define WIN32_LEAN_AND_MEAN # include # include using sock_t = SOCKET; static constexpr sock_t kBadSock = INVALID_SOCKET; static void close_sock(sock_t s) { closesocket(s); } static int sock_error() { return WSAGetLastError(); } #else # include # include # include # include # include using sock_t = int; static constexpr sock_t kBadSock = -1; static void close_sock(sock_t s) { ::close(s); } static int sock_error() { return errno; } #endif #include "crypto/crypto.h" #include "net/voice_frame.h" #include "protocol/envelope.h" #include "protocol/protocol.h" #include "server.h" #include "db.h" #ifdef VOICECAT_HAS_OPUS #include "codec/opus_codec.h" #endif using namespace voicecat; using namespace voicecat::net; using namespace voicecat::crypto; // ── helpers ─────────────────────────────────────────────────────────────────── static int g_failures = 0; #define CHECK(cond) \ do { if (!(cond)) { \ std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \ ++g_failures; \ }} while (0) // Blocking socket helpers static bool tcp_send_all(TlsContext& tls, const uint8_t* data, size_t len) { size_t off = 0; while (off < len) { int n = tls.write(data + off, len - off); if (n <= 0) return false; off += static_cast(n); } return true; } static bool tcp_send_envelope(TlsContext& tls, const v1::Envelope& env) { std::vector frame; protocol::encode_envelope(env, frame); return tcp_send_all(tls, frame.data(), frame.size()); } // Read one Envelope from TLS (blocking, with 5s timeout between reads). static bool tcp_recv_envelope(TlsContext& tls, protocol::FrameCodec& codec, v1::Envelope& out) { uint8_t buf[16384]; for (int i = 0; i < 100; ++i) { // 100 * 50ms = 5s int n = tls.read(buf, sizeof(buf)); if (TlsContext::is_timeout_error(n)) continue; if (n <= 0) return false; std::vector> frames; if (!codec.feed(buf, static_cast(n), frames)) return false; for (auto& f : frames) { if (protocol::decode_envelope(f, out)) return true; } } return false; } // ── UDP raw socket (BSD sockets, not UdpMediaChannel) ───────────────────────── // We use the raw BSD API here so we can set a receive timeout easily. static sock_t udp_bind_os(uint16_t& out_port) { #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa); #endif sock_t s = ::socket(AF_INET, SOCK_DGRAM, 0); if (s == kBadSock) return kBadSock; sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); addr.sin_port = 0; if (::bind(s, reinterpret_cast(&addr), sizeof(addr)) != 0) { close_sock(s); return kBadSock; } socklen_t len = sizeof(addr); ::getsockname(s, reinterpret_cast(&addr), &len); out_port = ntohs(addr.sin_port); // 500ms receive timeout #ifdef _WIN32 DWORD tv = 500; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&tv), sizeof(tv)); #else struct timeval tv{0, 500000}; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); #endif return s; } static bool udp_send(sock_t s, const uint8_t* data, size_t len, uint16_t dst_port) { sockaddr_in dst{}; dst.sin_family = AF_INET; dst.sin_addr.s_addr = htonl(INADDR_LOOPBACK); dst.sin_port = htons(dst_port); int n = static_cast(::sendto(s, reinterpret_cast(data), static_cast(len), 0, reinterpret_cast(&dst), sizeof(dst))); return n == static_cast(len); } static int udp_recv(sock_t s, uint8_t* buf, size_t cap) { return static_cast(::recv(s, reinterpret_cast(buf), static_cast(cap), 0)); } // ── TestClient ──────────────────────────────────────────────────────────────── struct TestClient { std::string label; sock_t tcp_sock = kBadSock; sock_t udp_sock = kBadSock; uint16_t udp_local_port = 0; std::unique_ptr tls; std::unique_ptr send_crypto; std::unique_ptr recv_crypto; protocol::FrameCodec codec; uint8_t udp_token[16]{}; uint16_t server_udp_port = 0; uint32_t assigned_ssrc = 0; bool stream_ok = false; std::atomic frames_received{0}; std::atomic decrypt_errors{0}; // Decoded payloads for verification std::mutex payloads_mu; std::vector> payloads; bool connect(const char* host, uint16_t port) { #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2,2), &wsa); #endif struct addrinfo hints{}; hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM; struct addrinfo* res = nullptr; if (getaddrinfo(host, std::to_string(port).c_str(), &hints, &res) != 0 || !res) return false; tcp_sock = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol); if (tcp_sock == kBadSock) { freeaddrinfo(res); return false; } if (::connect(tcp_sock, res->ai_addr, static_cast(res->ai_addrlen)) != 0) { close_sock(tcp_sock); tcp_sock = kBadSock; freeaddrinfo(res); return false; } freeaddrinfo(res); return true; } bool tls_handshake() { tls = std::make_unique(TlsContext::Role::Client, nullptr); tls->set_read_timeout(50); // 50ms read timeout for drain-between-reads std::string err; if (!tls->handshake(static_cast(tcp_sock), err)) { std::printf("[%s] TLS failed: %s\n", label.c_str(), err.c_str()); return false; } // Derive media keying material immediately after handshake. send_crypto = SodiumMediaCrypto::derive_send(*tls, true); // client sends on ctx=0x00 recv_crypto = SodiumMediaCrypto::derive_recv(*tls, true); // client recvs on ctx=0x01 return send_crypto && recv_crypto; } bool do_hello_and_auth(const char* nickname) { // ClientHello { v1::Envelope env; env.set_request_id(1); env.mutable_client_hello()->set_proto_version(1); env.mutable_client_hello()->set_client_name(label); if (!tcp_send_envelope(*tls, env)) return false; } // ServerHello { v1::Envelope env; if (!tcp_recv_envelope(*tls, codec, env)) return false; if (!env.has_server_hello()) return false; server_udp_port = static_cast(env.server_hello().udp_port()); } // AuthRequest (guest) { v1::Envelope env; env.set_request_id(2); env.mutable_auth_request()->mutable_guest()->set_nickname(nickname); if (!tcp_send_envelope(*tls, env)) return false; } // Wait for AuthResult (may be preceded by other messages) for (int attempt = 0; attempt < 20; ++attempt) { v1::Envelope env; if (!tcp_recv_envelope(*tls, codec, env)) return false; if (env.has_auth_result()) { if (!env.auth_result().ok()) return false; const auto& tok = env.auth_result().udp_token(); if (tok.size() == 16) std::memcpy(udp_token, tok.data(), 16); return true; } } return false; } // Drain any pending TCP messages (e.g., ServerState snapshot). void drain_incoming(int timeout_ms) { auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); while (std::chrono::steady_clock::now() < deadline) { v1::Envelope env; if (tcp_recv_envelope(*tls, codec, env)) { if (env.has_udp_binding()) { /* handled */ } if (env.has_stream_announce_result()) { /* handled below */ } } } } bool do_udp_binding() { // Bind local UDP socket. udp_sock = udp_bind_os(udp_local_port); if (udp_sock == kBadSock) return false; // Send TCP UdpBinding (declares intent to bind). { v1::Envelope env; env.set_request_id(3); env.mutable_udp_binding()->set_udp_token(udp_token, 16); if (!tcp_send_envelope(*tls, env)) return false; } // Wait for TCP UdpBinding ack. bool got_ack = false; for (int i = 0; i < 20 && !got_ack; ++i) { v1::Envelope env; if (!tcp_recv_envelope(*tls, codec, env)) break; if (env.has_udp_binding() && env.udp_binding().ack()) got_ack = true; } if (!got_ack) return false; // Send UDP binding packet (type=kFrameUdpBinding + token). auto pkt = make_udp_binding_packet(udp_token, 16); if (!udp_send(udp_sock, pkt.data(), pkt.size(), server_udp_port)) return false; // Brief pause to let the server process the UDP binding. std::this_thread::sleep_for(std::chrono::milliseconds(100)); return true; } bool do_stream_announce() { // Send StreamAnnounce. { v1::Envelope env; env.set_request_id(4); auto* ann = env.mutable_stream_announce(); ann->set_kind(v1::STREAM_MIC); auto* audio = ann->mutable_requested_audio(); audio->set_sample_rate(48000); audio->set_bitrate_bps(24000); audio->set_frame_ms(20); audio->set_fec(true); if (!tcp_send_envelope(*tls, env)) return false; } // Wait for StreamAnnounceResult. for (int i = 0; i < 20; ++i) { v1::Envelope env; if (!tcp_recv_envelope(*tls, codec, env)) return false; if (env.has_stream_announce_result()) { const auto& r = env.stream_announce_result(); if (r.ok()) { assigned_ssrc = r.ssrc(); stream_ok = true; return true; } return false; } } return false; } void close() { if (udp_sock != kBadSock) { close_sock(udp_sock); udp_sock = kBadSock; } if (tcp_sock != kBadSock) { close_sock(tcp_sock); tcp_sock = kBadSock; } tls.reset(); } }; // ── generate 440 Hz mono PCM (48 kHz, 20 ms = 960 samples) ─────────────────── static std::vector make_sine_frame(int frame_idx, int frame_samples = 960) { std::vector pcm(frame_samples); for (int i = 0; i < frame_samples; ++i) { float t = static_cast(frame_idx * frame_samples + i) / 48000.0f; pcm[i] = static_cast(std::sin(2.0f * 3.14159265f * 440.0f * t) * 16000.0f); } return pcm; } // ── main ────────────────────────────────────────────────────────────────────── int main() { // ── Temp data dir ──────────────────────────────────────────────────────── auto tmp = std::filesystem::temp_directory_path() / ("vctest_m2_" + std::to_string( std::chrono::steady_clock::now().time_since_epoch().count())); std::filesystem::create_directories(tmp); // ── Start server ───────────────────────────────────────────────────────── std::atomic tcp_port{0}, udp_port{0}; std::mutex ready_mu; std::condition_variable ready_cv; int ready_flags = 0; voicecat::server::Config cfg; cfg.data_dir = tmp.string(); cfg.bind_port = 0; cfg.media_port = 0; cfg.allow_guests = true; cfg.server_name = "VoiceCat-M2Test"; cfg.on_ready = [&](uint16_t p) { tcp_port.store(p); { std::lock_guard lk(ready_mu); ready_flags |= 1; } ready_cv.notify_all(); }; cfg.on_media_ready = [&](uint16_t p) { udp_port.store(p); { std::lock_guard lk(ready_mu); ready_flags |= 2; } ready_cv.notify_all(); }; voicecat::server::Server server(cfg); std::thread server_thread([&] { server.run(); }); { std::unique_lock lk(ready_mu); bool ok = ready_cv.wait_for(lk, std::chrono::seconds(15), [&] { return (ready_flags & 3) == 3; }); if (!ok) { std::printf("FAIL: server did not become ready in time\n"); server.stop(); server_thread.join(); std::filesystem::remove_all(tmp); return 1; } } std::printf("m2_voice: server TCP:%u UDP:%u\n", tcp_port.load(), udp_port.load()); // ── Client A ───────────────────────────────────────────────────────────── TestClient A; A.label = "ClientA"; CHECK(A.connect("127.0.0.1", tcp_port.load())); CHECK(A.tls_handshake()); CHECK(A.do_hello_and_auth("SenderBob")); CHECK(A.server_udp_port == udp_port.load()); CHECK(A.do_udp_binding()); CHECK(A.do_stream_announce()); CHECK(A.stream_ok); std::printf("m2_voice: A ssrc=%u local_udp=%u\n", A.assigned_ssrc, A.udp_local_port); // ── Client B ───────────────────────────────────────────────────────────── TestClient B; B.label = "ClientB"; CHECK(B.connect("127.0.0.1", tcp_port.load())); CHECK(B.tls_handshake()); CHECK(B.do_hello_and_auth("ReceiverAlice")); CHECK(B.server_udp_port == udp_port.load()); CHECK(B.do_udp_binding()); // B doesn't need to announce a stream to receive relayed frames if (g_failures > 0) { std::printf("m2_voice: setup failed — aborting\n"); A.close(); B.close(); server.stop(); server_thread.join(); std::filesystem::remove_all(tmp); return 1; } // ── A sends 50 Opus frames ──────────────────────────────────────────────── constexpr int kFramesToSend = 50; constexpr int kFrameSamples = 960; // 20 ms @48 kHz #ifdef VOICECAT_HAS_OPUS voicecat::codec::OpusEncoder enc; { voicecat::codec::OpusParams p; p.sample_rate = 48000; p.frame_ms = 20; p.fec = true; CHECK(enc.init(p)); } #endif std::vector aead_buf(4096); // scratch for (int i = 0; i < kFramesToSend; ++i) { auto pcm = make_sine_frame(i, kFrameSamples); #ifdef VOICECAT_HAS_OPUS uint8_t opus_buf[1000]; int opus_len = enc.encode(pcm.data(), kFrameSamples, opus_buf, sizeof(opus_buf)); if (opus_len <= 0) continue; const uint8_t* payload = opus_buf; size_t payload_len = static_cast(opus_len); #else // Fallback: use raw PCM as synthetic payload const uint8_t* payload = reinterpret_cast(pcm.data()); size_t payload_len = pcm.size() * sizeof(int16_t); #endif // Build 14-byte header (AAD). VoiceFrame hdr; hdr.ssrc = A.assigned_ssrc; hdr.seq = static_cast(i); hdr.timestamp = static_cast(i * kFrameSamples); uint8_t header_bytes[kVoiceHeaderSize]; serialize_header(hdr, header_bytes); // AEAD-seal the payload. size_t sealed_cap = payload_len + crypto_aead_chacha20poly1305_ietf_ABYTES; if (aead_buf.size() < kVoiceHeaderSize + sealed_cap) aead_buf.resize(kVoiceHeaderSize + sealed_cap); std::memcpy(aead_buf.data(), header_bytes, kVoiceHeaderSize); long sealed = A.send_crypto->seal(payload, payload_len, header_bytes, kVoiceHeaderSize, aead_buf.data() + kVoiceHeaderSize, sealed_cap); if (sealed < 0) continue; udp_send(A.udp_sock, aead_buf.data(), kVoiceHeaderSize + static_cast(sealed), udp_port.load()); // 20 ms inter-frame spacing to let the server process std::this_thread::sleep_for(std::chrono::milliseconds(20)); } #ifdef VOICECAT_HAS_OPUS enc.destroy(); #endif // ── B collects received frames (2s window after last send) ──────────────── int recv_count = 0, decrypt_ok = 0; auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); uint8_t udp_in[2048]; while (std::chrono::steady_clock::now() < deadline) { int n = udp_recv(B.udp_sock, udp_in, sizeof(udp_in)); if (n < static_cast(kVoiceHeaderSize)) continue; // Decrypt VoiceFrame hdr_in{}; parse_header(udp_in, static_cast(n), hdr_in); size_t sealed_len = static_cast(n) - kVoiceHeaderSize; std::vector plain(sealed_len); long plain_len = B.recv_crypto->open( udp_in + kVoiceHeaderSize, sealed_len, udp_in, kVoiceHeaderSize, plain.data(), plain.size()); recv_count++; if (plain_len >= 0) decrypt_ok++; } std::printf("m2_voice: A sent %d frames, B received %d, decrypted OK: %d\n", kFramesToSend, recv_count, decrypt_ok); CHECK(recv_count >= 25); // ≥ 50% of sent frames arrived CHECK(decrypt_ok == recv_count); // all received frames decrypt correctly // ── Cleanup ─────────────────────────────────────────────────────────────── A.close(); B.close(); server.stop(); server_thread.join(); std::filesystem::remove_all(tmp); if (g_failures == 0) { std::printf("m2_voice: all checks passed\n"); return 0; } std::printf("m2_voice: %d failure(s)\n", g_failures); return 1; } #else // !VOICECAT_HAS_NET int main() { std::printf("m2_voice: SKIP (VOICECAT_HAS_NET not defined)\n"); return 0; } #endif // VOICECAT_HAS_NET