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-admin — offline account management tool.
|
|
|
|
|
*
|
|
|
|
|
* Directly manipulates {data_dir}/voicecat.db without a running server.
|
|
|
|
|
* Usage:
|
|
|
|
|
* voicecat-admin [--data-dir <path>] account add <username> [--admin] [--password <p>]
|
|
|
|
|
* voicecat-admin [--data-dir <path>] account reset <username> [--password <p>]
|
|
|
|
|
* voicecat-admin [--data-dir <path>] account del <username>
|
|
|
|
|
* voicecat-admin [--data-dir <path>] account list
|
|
|
|
|
*/
|
|
|
|
|
#include <cstdio>
|
|
|
|
|
#include <cstring>
|
|
|
|
|
#include <iostream>
|
|
|
|
|
#include <optional>
|
|
|
|
|
#include <string>
|
|
|
|
|
|
|
|
|
|
#ifdef VOICECAT_HAS_NET
|
|
|
|
|
|
|
|
|
|
#include "db.h"
|
|
|
|
|
using namespace voicecat::server;
|
|
|
|
|
|
|
|
|
|
static void print_usage(const char* argv0) {
|
|
|
|
|
std::fprintf(stderr,
|
|
|
|
|
"Usage:\n"
|
|
|
|
|
" %s [--data-dir <path>] account add <user> [--admin] [--password <p>]\n"
|
|
|
|
|
" %s [--data-dir <path>] account reset <user> [--password <p>]\n"
|
|
|
|
|
" %s [--data-dir <path>] account del <user>\n"
|
|
|
|
|
" %s [--data-dir <path>] account list\n",
|
|
|
|
|
argv0, argv0, argv0, argv0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static std::string read_password_stdin(const char* prompt) {
|
|
|
|
|
std::fprintf(stderr, "%s: ", prompt);
|
|
|
|
|
std::fflush(stderr);
|
|
|
|
|
std::string pw;
|
|
|
|
|
std::getline(std::cin, pw);
|
|
|
|
|
return pw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int main(int argc, char** argv) {
|
|
|
|
|
std::string data_dir = "voicecat-data";
|
|
|
|
|
int i = 1;
|
|
|
|
|
|
|
|
|
|
// Parse --data-dir
|
|
|
|
|
if (i < argc && std::strcmp(argv[i], "--data-dir") == 0) {
|
|
|
|
|
if (++i >= argc) { std::fprintf(stderr, "Missing argument to --data-dir\n"); return 1; }
|
|
|
|
|
data_dir = argv[i++];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (i >= argc || std::strcmp(argv[i], "account") != 0) {
|
|
|
|
|
print_usage(argv[0]); return 1;
|
|
|
|
|
}
|
|
|
|
|
++i; // skip "account"
|
|
|
|
|
|
|
|
|
|
if (i >= argc) { print_usage(argv[0]); return 1; }
|
|
|
|
|
std::string subcmd = argv[i++];
|
|
|
|
|
|
|
|
|
|
std::string db_path = data_dir + "/voicecat.db";
|
|
|
|
|
Database db(db_path);
|
|
|
|
|
std::string error;
|
|
|
|
|
if (!db.open(error)) {
|
|
|
|
|
std::fprintf(stderr, "Failed to open database %s: %s\n", db_path.c_str(), error.c_str());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (subcmd == "list") {
|
|
|
|
|
auto accounts = db.list_accounts();
|
|
|
|
|
if (accounts.empty()) {
|
|
|
|
|
std::printf("(no accounts)\n");
|
|
|
|
|
} else {
|
|
|
|
|
std::printf("%-20s %-5s %s\n", "username", "admin", "created_at");
|
|
|
|
|
std::printf("%-20s %-5s %s\n", "--------", "-----", "----------");
|
|
|
|
|
for (auto& acc : accounts) {
|
|
|
|
|
std::printf("%-20s %-5s %lld\n",
|
|
|
|
|
acc.username.c_str(),
|
|
|
|
|
acc.is_admin ? "yes" : "no",
|
|
|
|
|
static_cast<long long>(acc.created_at));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (subcmd == "add") {
|
|
|
|
|
if (i >= argc) { std::fprintf(stderr, "add requires a username\n"); return 1; }
|
|
|
|
|
std::string username = argv[i++];
|
|
|
|
|
bool is_admin = false;
|
|
|
|
|
std::optional<std::string> password;
|
|
|
|
|
|
|
|
|
|
while (i < argc) {
|
|
|
|
|
if (std::strcmp(argv[i], "--admin") == 0) { is_admin = true; ++i; }
|
|
|
|
|
else if (std::strcmp(argv[i], "--password") == 0) {
|
|
|
|
|
if (++i >= argc) { std::fprintf(stderr, "Missing argument to --password\n"); return 1; }
|
|
|
|
|
password = argv[i++];
|
|
|
|
|
} else { std::fprintf(stderr, "Unknown option: %s\n", argv[i]); return 1; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!password) password = read_password_stdin("Password");
|
|
|
|
|
if (password->empty()) { std::fprintf(stderr, "Password must not be empty\n"); return 1; }
|
|
|
|
|
|
|
|
|
|
auto acc = db.create_account(username, *password, is_admin, error);
|
|
|
|
|
if (!acc) { std::fprintf(stderr, "Failed to create account: %s\n", error.c_str()); return 1; }
|
|
|
|
|
std::printf("Created account '%s'%s (id=%lld)\n",
|
|
|
|
|
acc->username.c_str(), is_admin ? " [admin]" : "",
|
|
|
|
|
static_cast<long long>(acc->id));
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (subcmd == "reset") {
|
|
|
|
|
if (i >= argc) { std::fprintf(stderr, "reset requires a username\n"); return 1; }
|
|
|
|
|
std::string username = argv[i++];
|
|
|
|
|
std::optional<std::string> password;
|
|
|
|
|
|
|
|
|
|
while (i < argc) {
|
|
|
|
|
if (std::strcmp(argv[i], "--password") == 0) {
|
|
|
|
|
if (++i >= argc) { std::fprintf(stderr, "Missing argument to --password\n"); return 1; }
|
|
|
|
|
password = argv[i++];
|
|
|
|
|
} else { std::fprintf(stderr, "Unknown option: %s\n", argv[i]); return 1; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!password) password = read_password_stdin("New password");
|
|
|
|
|
if (password->empty()) { std::fprintf(stderr, "Password must not be empty\n"); return 1; }
|
|
|
|
|
|
|
|
|
|
if (!db.reset_password(username, *password, error)) {
|
|
|
|
|
std::fprintf(stderr, "Failed: %s\n", error.c_str()); return 1;
|
|
|
|
|
}
|
|
|
|
|
std::printf("Password reset for '%s'\n", username.c_str());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (subcmd == "del") {
|
|
|
|
|
if (i >= argc) { std::fprintf(stderr, "del requires a username\n"); return 1; }
|
|
|
|
|
std::string username = argv[i++];
|
|
|
|
|
if (!db.delete_account(username, error)) {
|
|
|
|
|
std::fprintf(stderr, "Failed: %s\n", error.c_str()); return 1;
|
|
|
|
|
}
|
|
|
|
|
std::printf("Deleted account '%s'\n", username.c_str());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::fprintf(stderr, "Unknown subcommand: %s\n", subcmd.c_str());
|
|
|
|
|
print_usage(argv[0]);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#else // !VOICECAT_HAS_NET
|
|
|
|
|
|
|
|
|
|
int main() {
|
build(cmake): clean up presets, add release/apple presets, cross-platform triplets
Rationalize the preset set to match the project's actual state (past M5):
- Rename dev->skeleton (no-deps stub smoke), m1-dev->dev (default dev preset)
- Drop m2-dev (cache-identical to m1-dev)
- Add release preset (optimized + tests on, symbols kept)
- Strip server-release binaries (-s linker flag)
- Add apple-dev/apple-ios/apple-ios-sim scaffolding presets for XCFramework
Add cmake/voicecat-toolchain.cmake wrapper that auto-resolves the vcpkg
triplet from the host platform (x64-mingw-static/x64-linux/arm64-osx) so
the main presets work on Windows/Linux/macOS without per-OS variants.
Update all docs (building.md, CLAUDE.md, README.md, AGENTS.md, deployment.md,
tech-stack.md, client READMEs) and stale preset-name references in code
comments. No C++ behavior changes — the core was already portable.
2026-06-18 03:16:01 +02:00
|
|
|
std::fprintf(stderr, "voicecat-admin requires VOICECAT_HAS_NET (build with dev preset)\n");
|
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
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endif // VOICECAT_HAS_NET
|