main.cpp 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
  1. #include <smartbotic/database/client.hpp>
  2. #include <nlohmann/json.hpp>
  3. #include "storage/subdb_placement.hpp"
  4. #include <openssl/bio.h>
  5. #include <openssl/bn.h>
  6. #include <openssl/err.h>
  7. #include <openssl/evp.h>
  8. #include <openssl/pem.h>
  9. #include <openssl/rand.h>
  10. #include <openssl/x509.h>
  11. #include <openssl/x509v3.h>
  12. #include <arpa/inet.h>
  13. #include <fcntl.h>
  14. #include <sys/random.h>
  15. #include <sys/stat.h>
  16. #include <unistd.h>
  17. #include <cerrno>
  18. #include <cstdio>
  19. #include <cstring>
  20. #include <fstream>
  21. #include <iostream>
  22. #include <sstream>
  23. #include <string>
  24. #include <vector>
  25. using json = nlohmann::json;
  26. namespace {
  27. struct Args {
  28. std::string address = "localhost:9004";
  29. std::string command;
  30. std::vector<std::string> params;
  31. };
  32. // ANSI colors
  33. constexpr auto C_RESET = "\033[0m";
  34. constexpr auto C_BOLD = "\033[1m";
  35. constexpr auto C_DIM = "\033[2m";
  36. constexpr auto C_CYAN = "\033[36m";
  37. constexpr auto C_GREEN = "\033[32m";
  38. constexpr auto C_RED = "\033[31m";
  39. constexpr auto C_YELLOW = "\033[33m";
  40. void printJson(const json& j) {
  41. std::cout << j.dump(2) << "\n";
  42. }
  43. void printError(const std::string& msg) {
  44. std::cerr << C_RED << "error: " << C_RESET << msg << "\n";
  45. }
  46. void printUsage() {
  47. std::cout << C_BOLD << "smartbotic-db-cli" << C_RESET << " — admin tool for smartbotic-database\n\n"
  48. << C_BOLD << "Usage:" << C_RESET << "\n"
  49. << " smartbotic-db-cli [--address HOST:PORT] <command> [args...]\n"
  50. << " smartbotic-db-cli [--address HOST:PORT] " << C_DIM << "# interactive mode" << C_RESET << "\n\n"
  51. << C_BOLD << "Commands:" << C_RESET << "\n"
  52. << " " << C_CYAN << "collections" << C_RESET << " List all collections\n"
  53. << " " << C_CYAN << "info" << C_RESET << " <collection> Collection info\n"
  54. << " " << C_CYAN << "find" << C_RESET << " <collection> List documents\n"
  55. << " " << C_CYAN << "get" << C_RESET << " <collection> <id> Get a document\n"
  56. << " " << C_CYAN << "upsert" << C_RESET << " <collection> <id> '<json>' Insert or update\n"
  57. << " " << C_CYAN << "remove" << C_RESET << " <collection> <id> Delete a document\n"
  58. << " " << C_CYAN << "count" << C_RESET << " <collection> Count documents\n"
  59. << " " << C_CYAN << "lock" << C_RESET << " Lock database (read-only)\n"
  60. << " " << C_CYAN << "unlock" << C_RESET << " Unlock database (accept writes)\n"
  61. << " " << C_CYAN << "status" << C_RESET << " Show read-only + recovery status\n"
  62. << C_BOLD << " Access policy (v2.7.0+)" << C_RESET << "\n"
  63. << " " << C_CYAN << "security" << C_RESET << " [project] Show whether a project enforces policy\n"
  64. << " " << C_CYAN << "security-set" << C_RESET << " <project> <on|off> [enforce|audit]\n"
  65. << " " << C_CYAN << "policies" << C_RESET << " [project] List principals with a policy\n"
  66. << " " << C_CYAN << "policy" << C_RESET << " <project> <principal> Show one policy\n"
  67. << " " << C_CYAN << "policy-set" << C_RESET << " <project> <principal> '<json>'\n"
  68. << " " << C_CYAN << "policy-rm" << C_RESET << " <project> <principal>\n"
  69. << " " << C_CYAN << "help" << C_RESET << " Show this help\n\n"
  70. << C_BOLD << "Offline helpers" << C_RESET << " " << C_DIM << "(no server connection required)" << C_RESET << ":\n"
  71. << " " << C_CYAN << "generate-auth-key" << C_RESET << " Emit a base64 32-byte API key\n"
  72. << " " << C_CYAN << "generate-tls-cert" << C_RESET << " --bind <addr> Generate a self-signed TLS cert + key\n"
  73. << " " << C_DIM << "[--out-cert PATH] [--out-key PATH] [--days N]" << C_RESET << "\n"
  74. << " " << C_CYAN << "verify-subdbs" << C_RESET << " --env <path> Report documents filed under the wrong collection\n"
  75. << " " << C_DIM << "[--project NAME] read-only; safe against a running server" << C_RESET << "\n"
  76. << " " << C_CYAN << "reconcile-subdbs" << C_RESET << " --env <path> Repair misfiled documents (dry run by default)\n"
  77. << " " << C_DIM << "[--project NAME] [--apply] STOP THE SERVICE and back up before --apply" << C_RESET << "\n\n"
  78. << C_BOLD << "Options:" << C_RESET << "\n"
  79. << " --address HOST:PORT Database address (default: localhost:9004)\n";
  80. }
  81. // ---------------------------------------------------------------------------
  82. // v2.4 Stage F: offline helpers (no server connection required)
  83. // ---------------------------------------------------------------------------
  84. // Base64-encode raw bytes. Uses OpenSSL's EVP_EncodeBlock which emits the
  85. // standard base64 alphabet (no newlines) and pads with '='.
  86. std::string base64Encode(const unsigned char* data, size_t len) {
  87. if (len == 0) return {};
  88. // EVP_EncodeBlock writes ((len + 2) / 3) * 4 bytes + a NUL terminator.
  89. const size_t out_len = 4 * ((len + 2) / 3);
  90. std::string out(out_len, '\0');
  91. int written = EVP_EncodeBlock(
  92. reinterpret_cast<unsigned char*>(out.data()),
  93. data, static_cast<int>(len));
  94. if (written < 0) return {};
  95. out.resize(static_cast<size_t>(written));
  96. return out;
  97. }
  98. // Pull 32 cryptographically random bytes from getentropy() (preferred) or
  99. // /dev/urandom (fallback). Returns true on success.
  100. bool fillRandomBytes(unsigned char* buf, size_t len) {
  101. // getentropy() is limited to 256 bytes per call; our use-case is 32.
  102. if (len <= 256) {
  103. if (getentropy(buf, len) == 0) return true;
  104. }
  105. // Fallback: /dev/urandom.
  106. int fd = ::open("/dev/urandom", O_RDONLY | O_CLOEXEC);
  107. if (fd < 0) return false;
  108. size_t got = 0;
  109. while (got < len) {
  110. ssize_t n = ::read(fd, buf + got, len - got);
  111. if (n <= 0) {
  112. if (errno == EINTR) continue;
  113. ::close(fd);
  114. return false;
  115. }
  116. got += static_cast<size_t>(n);
  117. }
  118. ::close(fd);
  119. return true;
  120. }
  121. int cmdGenerateAuthKey() {
  122. unsigned char key[32];
  123. if (!fillRandomBytes(key, sizeof(key))) {
  124. std::fprintf(stderr, "error: failed to obtain entropy for key\n");
  125. return 1;
  126. }
  127. auto encoded = base64Encode(key, sizeof(key));
  128. if (encoded.empty()) {
  129. std::fprintf(stderr, "error: base64 encoding failed\n");
  130. return 1;
  131. }
  132. std::cout << encoded << "\n";
  133. return 0;
  134. }
  135. namespace {
  136. // Print the topmost OpenSSL error to stderr with a prefix.
  137. void printOpenSslError(const char* prefix) {
  138. unsigned long e = ERR_get_error();
  139. char buf[256] = {0};
  140. if (e != 0) {
  141. ERR_error_string_n(e, buf, sizeof(buf));
  142. std::fprintf(stderr, "error: %s: %s\n", prefix, buf);
  143. } else {
  144. std::fprintf(stderr, "error: %s\n", prefix);
  145. }
  146. }
  147. // Returns true if `s` parses as an IPv4 or IPv6 literal.
  148. bool looksLikeIp(const std::string& s) {
  149. unsigned char buf[16];
  150. if (inet_pton(AF_INET, s.c_str(), buf) == 1) return true;
  151. if (inet_pton(AF_INET6, s.c_str(), buf) == 1) return true;
  152. return false;
  153. }
  154. // Generate an RSA private key as an EVP_PKEY using the modern (3.x) API.
  155. EVP_PKEY* generateRsaKey(int bits) {
  156. EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr);
  157. if (!ctx) return nullptr;
  158. EVP_PKEY* pkey = nullptr;
  159. if (EVP_PKEY_keygen_init(ctx) <= 0) {
  160. EVP_PKEY_CTX_free(ctx);
  161. return nullptr;
  162. }
  163. if (EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits) <= 0) {
  164. EVP_PKEY_CTX_free(ctx);
  165. return nullptr;
  166. }
  167. if (EVP_PKEY_keygen(ctx, &pkey) <= 0) {
  168. EVP_PKEY_CTX_free(ctx);
  169. return nullptr;
  170. }
  171. EVP_PKEY_CTX_free(ctx);
  172. return pkey;
  173. }
  174. // Write a string to disk with the given file mode. Truncates existing files.
  175. bool writeFileWithMode(const std::string& path, const std::string& contents, mode_t mode) {
  176. int fd = ::open(path.c_str(),
  177. O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC,
  178. mode);
  179. if (fd < 0) {
  180. std::fprintf(stderr, "error: open %s: %s\n", path.c_str(), std::strerror(errno));
  181. return false;
  182. }
  183. // Re-assert mode in case the file pre-existed with a wider mode and
  184. // O_CREAT was a no-op (open(2) only applies the mode on create).
  185. if (fchmod(fd, mode) != 0) {
  186. std::fprintf(stderr, "error: fchmod %s: %s\n", path.c_str(), std::strerror(errno));
  187. ::close(fd);
  188. return false;
  189. }
  190. size_t off = 0;
  191. while (off < contents.size()) {
  192. ssize_t n = ::write(fd, contents.data() + off, contents.size() - off);
  193. if (n <= 0) {
  194. if (errno == EINTR) continue;
  195. std::fprintf(stderr, "error: write %s: %s\n", path.c_str(), std::strerror(errno));
  196. ::close(fd);
  197. return false;
  198. }
  199. off += static_cast<size_t>(n);
  200. }
  201. if (::close(fd) != 0) {
  202. std::fprintf(stderr, "error: close %s: %s\n", path.c_str(), std::strerror(errno));
  203. return false;
  204. }
  205. return true;
  206. }
  207. // Serialize an X509* to a PEM string.
  208. std::string pemEncodeCert(X509* cert) {
  209. BIO* bio = BIO_new(BIO_s_mem());
  210. if (!bio) return {};
  211. if (PEM_write_bio_X509(bio, cert) != 1) {
  212. BIO_free(bio);
  213. return {};
  214. }
  215. BUF_MEM* mem = nullptr;
  216. BIO_get_mem_ptr(bio, &mem);
  217. std::string out(mem->data, mem->length);
  218. BIO_free(bio);
  219. return out;
  220. }
  221. // Serialize an EVP_PKEY* to an unencrypted PEM string (PKCS#8 format via
  222. // PEM_write_bio_PrivateKey).
  223. std::string pemEncodeKey(EVP_PKEY* key) {
  224. BIO* bio = BIO_new(BIO_s_mem());
  225. if (!bio) return {};
  226. if (PEM_write_bio_PrivateKey(bio, key, nullptr, nullptr, 0, nullptr, nullptr) != 1) {
  227. BIO_free(bio);
  228. return {};
  229. }
  230. BUF_MEM* mem = nullptr;
  231. BIO_get_mem_ptr(bio, &mem);
  232. std::string out(mem->data, mem->length);
  233. BIO_free(bio);
  234. return out;
  235. }
  236. } // anonymous namespace
  237. int cmdGenerateTlsCert(const std::vector<std::string>& params) {
  238. std::string bind;
  239. std::string out_cert = "./server.pem";
  240. std::string out_key = "./server.key";
  241. int days = 3650;
  242. for (size_t i = 0; i < params.size(); ++i) {
  243. const auto& p = params[i];
  244. auto need = [&](const char* flag) -> const std::string* {
  245. if (i + 1 >= params.size()) {
  246. std::fprintf(stderr, "error: %s requires a value\n", flag);
  247. return nullptr;
  248. }
  249. return &params[++i];
  250. };
  251. if (p == "--bind") {
  252. auto* v = need("--bind"); if (!v) return 2; bind = *v;
  253. } else if (p == "--out-cert") {
  254. auto* v = need("--out-cert"); if (!v) return 2; out_cert = *v;
  255. } else if (p == "--out-key") {
  256. auto* v = need("--out-key"); if (!v) return 2; out_key = *v;
  257. } else if (p == "--days") {
  258. auto* v = need("--days"); if (!v) return 2;
  259. try { days = std::stoi(*v); }
  260. catch (...) {
  261. std::fprintf(stderr, "error: --days must be an integer\n");
  262. return 2;
  263. }
  264. if (days <= 0) {
  265. std::fprintf(stderr, "error: --days must be > 0\n");
  266. return 2;
  267. }
  268. } else {
  269. std::fprintf(stderr, "error: unknown argument: %s\n", p.c_str());
  270. return 2;
  271. }
  272. }
  273. if (bind.empty()) {
  274. std::fprintf(stderr,
  275. "usage: generate-tls-cert --bind <addr> "
  276. "[--out-cert PATH] [--out-key PATH] [--days N]\n");
  277. return 2;
  278. }
  279. // 1. RSA 4096-bit key.
  280. EVP_PKEY* pkey = generateRsaKey(4096);
  281. if (!pkey) {
  282. printOpenSslError("RSA key generation failed");
  283. return 1;
  284. }
  285. // 2. X.509 certificate.
  286. X509* cert = X509_new();
  287. if (!cert) {
  288. printOpenSslError("X509_new failed");
  289. EVP_PKEY_free(pkey);
  290. return 1;
  291. }
  292. // Version 3 (the integer field encodes v3 as 2).
  293. if (X509_set_version(cert, 2) != 1) {
  294. printOpenSslError("X509_set_version failed");
  295. X509_free(cert); EVP_PKEY_free(pkey);
  296. return 1;
  297. }
  298. // Random 64-bit serial number.
  299. {
  300. unsigned char serial_bytes[8];
  301. if (RAND_bytes(serial_bytes, sizeof(serial_bytes)) != 1) {
  302. printOpenSslError("RAND_bytes for serial failed");
  303. X509_free(cert); EVP_PKEY_free(pkey);
  304. return 1;
  305. }
  306. // Mask the top bit so the BIGNUM is positive.
  307. serial_bytes[0] &= 0x7F;
  308. BIGNUM* bn = BN_bin2bn(serial_bytes, sizeof(serial_bytes), nullptr);
  309. if (!bn) {
  310. printOpenSslError("BN_bin2bn failed");
  311. X509_free(cert); EVP_PKEY_free(pkey);
  312. return 1;
  313. }
  314. ASN1_INTEGER* ai = BN_to_ASN1_INTEGER(bn, nullptr);
  315. BN_free(bn);
  316. if (!ai) {
  317. printOpenSslError("BN_to_ASN1_INTEGER failed");
  318. X509_free(cert); EVP_PKEY_free(pkey);
  319. return 1;
  320. }
  321. if (X509_set_serialNumber(cert, ai) != 1) {
  322. printOpenSslError("X509_set_serialNumber failed");
  323. ASN1_INTEGER_free(ai);
  324. X509_free(cert); EVP_PKEY_free(pkey);
  325. return 1;
  326. }
  327. ASN1_INTEGER_free(ai);
  328. }
  329. // Validity period.
  330. if (!X509_gmtime_adj(X509_get_notBefore(cert), 0)) {
  331. printOpenSslError("X509_gmtime_adj(notBefore) failed");
  332. X509_free(cert); EVP_PKEY_free(pkey);
  333. return 1;
  334. }
  335. long seconds = static_cast<long>(days) * 24L * 60L * 60L;
  336. if (!X509_gmtime_adj(X509_get_notAfter(cert), seconds)) {
  337. printOpenSslError("X509_gmtime_adj(notAfter) failed");
  338. X509_free(cert); EVP_PKEY_free(pkey);
  339. return 1;
  340. }
  341. // Public key.
  342. if (X509_set_pubkey(cert, pkey) != 1) {
  343. printOpenSslError("X509_set_pubkey failed");
  344. X509_free(cert); EVP_PKEY_free(pkey);
  345. return 1;
  346. }
  347. // Subject + issuer name (self-signed, so identical).
  348. X509_NAME* name = X509_get_subject_name(cert);
  349. if (X509_NAME_add_entry_by_txt(
  350. name, "CN", MBSTRING_UTF8,
  351. reinterpret_cast<const unsigned char*>(bind.c_str()),
  352. -1, -1, 0) != 1) {
  353. printOpenSslError("X509_NAME_add_entry_by_txt(CN) failed");
  354. X509_free(cert); EVP_PKEY_free(pkey);
  355. return 1;
  356. }
  357. if (X509_set_issuer_name(cert, name) != 1) {
  358. printOpenSslError("X509_set_issuer_name failed");
  359. X509_free(cert); EVP_PKEY_free(pkey);
  360. return 1;
  361. }
  362. // SubjectAltName.
  363. {
  364. std::string san = "DNS:localhost,IP:127.0.0.1";
  365. if (bind != "localhost" && bind != "127.0.0.1") {
  366. san += ',';
  367. san += looksLikeIp(bind) ? "IP:" : "DNS:";
  368. san += bind;
  369. }
  370. X509_EXTENSION* ext = X509V3_EXT_conf_nid(
  371. nullptr, nullptr, NID_subject_alt_name, san.c_str());
  372. if (!ext) {
  373. printOpenSslError("X509V3_EXT_conf_nid(SAN) failed");
  374. X509_free(cert); EVP_PKEY_free(pkey);
  375. return 1;
  376. }
  377. if (X509_add_ext(cert, ext, -1) != 1) {
  378. printOpenSslError("X509_add_ext(SAN) failed");
  379. X509_EXTENSION_free(ext);
  380. X509_free(cert); EVP_PKEY_free(pkey);
  381. return 1;
  382. }
  383. X509_EXTENSION_free(ext);
  384. }
  385. // basicConstraints CA:FALSE — a server leaf cert, not a CA.
  386. {
  387. X509_EXTENSION* ext = X509V3_EXT_conf_nid(
  388. nullptr, nullptr, NID_basic_constraints, "critical,CA:FALSE");
  389. if (ext) {
  390. X509_add_ext(cert, ext, -1);
  391. X509_EXTENSION_free(ext);
  392. }
  393. }
  394. // Sign with SHA-256.
  395. if (X509_sign(cert, pkey, EVP_sha256()) == 0) {
  396. printOpenSslError("X509_sign failed");
  397. X509_free(cert); EVP_PKEY_free(pkey);
  398. return 1;
  399. }
  400. // Serialize.
  401. std::string cert_pem = pemEncodeCert(cert);
  402. std::string key_pem = pemEncodeKey(pkey);
  403. X509_free(cert);
  404. EVP_PKEY_free(pkey);
  405. if (cert_pem.empty() || key_pem.empty()) {
  406. std::fprintf(stderr, "error: PEM encoding failed\n");
  407. return 1;
  408. }
  409. // Write key first (0600), then cert (0644). If either fails, the other
  410. // may have been written — that's acceptable; the operator will see the
  411. // error and retry.
  412. if (!writeFileWithMode(out_key, key_pem, 0600)) return 1;
  413. if (!writeFileWithMode(out_cert, cert_pem, 0644)) return 1;
  414. std::cout << "Wrote cert: " << out_cert << "\n"
  415. << "Wrote key: " << out_key << "\n";
  416. return 0;
  417. }
  418. bool execCommand(smartbotic::database::Client& client,
  419. const std::string& cmd, const std::vector<std::string>& params) {
  420. try {
  421. if (cmd == "collections") {
  422. auto collections = client.listCollections();
  423. std::cout << C_BOLD << "Collections:" << C_RESET << "\n";
  424. for (const auto& c : collections) {
  425. auto info = client.getCollectionInfo(c);
  426. int64_t count = 0;
  427. if (info) count = info->documentCount;
  428. std::cout << " " << C_CYAN << c << C_RESET
  429. << C_DIM << " (" << count << " docs)" << C_RESET << "\n";
  430. }
  431. return true;
  432. }
  433. if (cmd == "info") {
  434. if (params.empty()) { printError("usage: info <collection>"); return false; }
  435. auto info = client.getCollectionInfo(params[0]);
  436. if (!info) { printError("collection not found: " + params[0]); return false; }
  437. std::cout << C_BOLD << params[0] << C_RESET << ":\n"
  438. << " documents: " << info->documentCount << "\n"
  439. << " size: " << info->sizeBytes << " bytes\n"
  440. << " encrypted: " << (info->encrypted ? "yes" : "no") << "\n"
  441. << " max_versions: " << info->maxVersions << "\n";
  442. if (info->defaultTtlSeconds > 0)
  443. std::cout << " ttl: " << info->defaultTtlSeconds << "s\n";
  444. return true;
  445. }
  446. if (cmd == "find") {
  447. if (params.empty()) { printError("usage: find <collection> [--limit N] [--exists FIELD]"); return false; }
  448. smartbotic::database::Client::QueryOptions opts;
  449. opts.limit = 100;
  450. for (size_t i = 1; i < params.size(); ++i) {
  451. if (params[i] == "--limit" && i + 1 < params.size()) {
  452. opts.limit = static_cast<uint32_t>(std::stoul(params[++i]));
  453. } else if (params[i] == "--exists" && i + 1 < params.size()) {
  454. opts.filters.emplace_back(params[++i], smartbotic::database::Client::FilterOp::EXISTS, true);
  455. }
  456. }
  457. auto docs = client.find(params[0], opts);
  458. std::cout << C_DIM << "(" << docs.size() << " documents)" << C_RESET << "\n";
  459. for (const auto& doc : docs) {
  460. auto id = doc.value("_id", "");
  461. // Print compact summary line
  462. std::cout << C_GREEN << id << C_RESET;
  463. // Show a few key fields
  464. for (const auto& [k, v] : doc.items()) {
  465. if (k == "_id" || k == "_created_at" || k == "_updated_at") continue;
  466. auto val = v.dump();
  467. if (val.size() > 60) val = val.substr(0, 57) + "...";
  468. std::cout << " " << C_DIM << k << "=" << C_RESET << val;
  469. // Limit to 3 fields per line
  470. static int field_count = 0;
  471. if (++field_count >= 3) { field_count = 0; break; }
  472. }
  473. std::cout << "\n";
  474. }
  475. return true;
  476. }
  477. if (cmd == "get") {
  478. if (params.size() < 2) { printError("usage: get <collection> <id>"); return false; }
  479. auto doc = client.get(params[0], params[1]);
  480. if (!doc) { printError("not found: " + params[0] + "/" + params[1]); return false; }
  481. printJson(*doc);
  482. return true;
  483. }
  484. if (cmd == "upsert") {
  485. if (params.size() < 3) { printError("usage: upsert <collection> <id> '<json>'"); return false; }
  486. auto data = json::parse(params[2], nullptr, false);
  487. if (!data.is_object()) { printError("invalid JSON: " + params[2]); return false; }
  488. client.upsert(params[0], data, params[1]);
  489. std::cout << C_GREEN << "ok" << C_RESET << " " << params[0] << "/" << params[1] << "\n";
  490. return true;
  491. }
  492. if (cmd == "remove" || cmd == "delete") {
  493. if (params.size() < 2) { printError("usage: remove <collection> <id>"); return false; }
  494. client.remove(params[0], params[1]);
  495. std::cout << C_GREEN << "ok" << C_RESET << " removed " << params[0] << "/" << params[1] << "\n";
  496. return true;
  497. }
  498. if (cmd == "count") {
  499. if (params.empty()) { printError("usage: count <collection>"); return false; }
  500. auto info = client.getCollectionInfo(params[0]);
  501. if (!info) { printError("collection not found: " + params[0]); return false; }
  502. std::cout << info->documentCount << "\n";
  503. return true;
  504. }
  505. // ===== v2.7.0 access policy =====
  506. //
  507. // Policy lives in the `_policies` collection and is managed through the
  508. // ordinary document API, which the server intercepts so edits refresh
  509. // its cache and the `__security__` record goes through the lockout
  510. // guards. These commands are ergonomics over that, not a second
  511. // mechanism - which is why there is no policy RPC to keep in step.
  512. if (cmd == "policies") {
  513. const std::string project = params.empty() ? "default" : params[0];
  514. auto rows = client.find("_policies", smartbotic::database::Client::QueryOptions{.limit = 1000});
  515. std::cout << C_BOLD << "policies in project '" << project << "'" << C_RESET << "\n";
  516. size_t shown = 0;
  517. for (const auto& r : rows) {
  518. const std::string id = r.value("_id", "");
  519. if (id.rfind(project + ":", 0) != 0) continue;
  520. const std::string tail = id.substr(project.size() + 1);
  521. if (tail == "__security__") continue;
  522. std::cout << " " << C_CYAN << tail << C_RESET
  523. << (r.value("admin", false) ? " (admin)" : "") << "\n";
  524. ++shown;
  525. }
  526. if (shown == 0) std::cout << C_DIM << " (none)" << C_RESET << "\n";
  527. return true;
  528. }
  529. if (cmd == "policy") {
  530. if (params.size() < 2) {
  531. printError("usage: policy <project> <principal>");
  532. return false;
  533. }
  534. auto doc = client.get("_policies", params[0] + ":" + params[1]);
  535. if (!doc) { printError("no policy for " + params[0] + ":" + params[1]); return false; }
  536. printJson(*doc);
  537. return true;
  538. }
  539. if (cmd == "policy-set") {
  540. if (params.size() < 3) {
  541. printError("usage: policy-set <project> <principal> '<json>'\n"
  542. " e.g. policy-set acme svc "
  543. "'{\"collections\":{\"users\":{\"read\":true,\"mask\":[\"ssn\"]}}}'\n"
  544. " admin: policy-set acme ops '{\"admin\":true}'");
  545. return false;
  546. }
  547. try {
  548. auto body = json::parse(params[2]);
  549. client.upsert("_policies", body, params[0] + ":" + params[1]);
  550. std::cout << C_GREEN << "ok" << C_RESET << " policy set for "
  551. << params[0] << ":" << params[1] << "\n";
  552. return true;
  553. } catch (const std::exception& e) {
  554. printError(e.what());
  555. return false;
  556. }
  557. }
  558. if (cmd == "policy-rm") {
  559. if (params.size() < 2) { printError("usage: policy-rm <project> <principal>"); return false; }
  560. try {
  561. bool ok = client.remove("_policies", params[0] + ":" + params[1]);
  562. std::cout << (ok ? "removed\n" : "not found\n");
  563. return ok;
  564. } catch (const std::exception& e) {
  565. // The server refuses to remove the last admin of a secured
  566. // project - that refusal is the lockout guard, not an error to
  567. // work around.
  568. printError(e.what());
  569. return false;
  570. }
  571. }
  572. if (cmd == "security") {
  573. const std::string project = params.empty() ? "default" : params[0];
  574. auto doc = client.get("_policies", project + ":__security__");
  575. if (!doc) {
  576. std::cout << "project '" << project << "': security "
  577. << C_GREEN << "disabled" << C_RESET
  578. << C_DIM << " (default - all access allowed)" << C_RESET << "\n";
  579. return true;
  580. }
  581. const bool on = doc->value("enabled", false);
  582. const std::string mode = doc->value("mode", "enforce");
  583. std::cout << "project '" << project << "': security "
  584. << (on ? (mode == "audit" ? C_YELLOW : C_RED) : C_GREEN)
  585. << (on ? (mode == "audit" ? "AUDIT" : "ENFORCED") : "disabled")
  586. << C_RESET << "\n";
  587. if (on && mode == "audit") {
  588. std::cout << C_DIM << " audit mode logs what it would deny and "
  589. "allows the request - watch the service log, then "
  590. "switch to enforce." << C_RESET << "\n";
  591. }
  592. return true;
  593. }
  594. if (cmd == "security-set") {
  595. if (params.size() < 2) {
  596. printError("usage: security-set <project> <on|off> [enforce|audit]\n"
  597. " Enabling is REFUSED unless some policy in the project has "
  598. "admin=true.\n"
  599. " Switch a live project on with 'audit' first.");
  600. return false;
  601. }
  602. const bool on = params[1] == "on" || params[1] == "true";
  603. const std::string mode = params.size() > 2 ? params[2] : "enforce";
  604. if (mode != "enforce" && mode != "audit") {
  605. printError("mode must be 'enforce' or 'audit'");
  606. return false;
  607. }
  608. try {
  609. json body;
  610. body["enabled"] = on;
  611. body["mode"] = mode;
  612. client.upsert("_policies", body, params[0] + ":__security__");
  613. std::cout << C_GREEN << "ok" << C_RESET << " project '" << params[0]
  614. << "' security " << (on ? mode : "disabled") << "\n";
  615. return true;
  616. } catch (const std::exception& e) {
  617. printError(e.what());
  618. return false;
  619. }
  620. }
  621. if (cmd == "lock") {
  622. if (client.setReadOnly(true)) {
  623. std::cout << C_GREEN << "ok" << C_RESET << " Database locked (read-only)\n";
  624. return true;
  625. }
  626. printError("failed to lock database");
  627. return false;
  628. }
  629. if (cmd == "unlock") {
  630. if (client.setReadOnly(false)) {
  631. std::cout << C_GREEN << "ok" << C_RESET << " Database unlocked (writes accepted)\n";
  632. return true;
  633. }
  634. printError("failed to unlock database");
  635. return false;
  636. }
  637. if (cmd == "status") {
  638. auto s = client.getReadOnlyStatus();
  639. std::cout << "Read-only: " << (s.readOnly ? (std::string(C_RED) + "YES" + C_RESET) : "no") << "\n";
  640. if (s.readOnly) {
  641. std::cout << "Reason: " << s.reason << "\n";
  642. }
  643. std::cout << "Recovery outcome: " << s.recoveryOutcome << "\n";
  644. if (!s.expectedSnapshot.empty()) {
  645. std::cout << "Expected snapshot: " << s.expectedSnapshot << "\n";
  646. }
  647. if (!s.snapshotUsed.empty()) {
  648. std::cout << "Snapshot used: " << s.snapshotUsed << "\n";
  649. }
  650. if (!s.failureReason.empty()) {
  651. std::cout << "Failure reason: " << s.failureReason << "\n";
  652. }
  653. std::cout << "WAL replayed: " << s.walEntriesReplayed << " entries\n";
  654. std::cout << "Snapshots tried: " << s.snapshotsAttempted << "\n";
  655. return true;
  656. }
  657. if (cmd == "help" || cmd == "?") {
  658. printUsage();
  659. return true;
  660. }
  661. printError("unknown command: " + cmd + " (try 'help')");
  662. return false;
  663. } catch (const std::exception& e) {
  664. printError(e.what());
  665. return false;
  666. }
  667. }
  668. // Tokenize a line, respecting single/double quotes and JSON braces
  669. std::vector<std::string> tokenize(const std::string& line) {
  670. std::vector<std::string> tokens;
  671. std::string current;
  672. int brace_depth = 0;
  673. char in_quote = 0;
  674. for (size_t i = 0; i < line.size(); ++i) {
  675. char c = line[i];
  676. if (in_quote) {
  677. current += c;
  678. if (c == in_quote && (i == 0 || line[i-1] != '\\')) {
  679. in_quote = 0;
  680. // Strip surrounding quotes for simple string tokens
  681. if (brace_depth == 0 && current.size() >= 2
  682. && (current.front() == '\'' || current.front() == '"')
  683. && current.front() == current.back()) {
  684. current = current.substr(1, current.size() - 2);
  685. }
  686. }
  687. continue;
  688. }
  689. if (c == '\'' || c == '"') {
  690. in_quote = c;
  691. current += c;
  692. continue;
  693. }
  694. if (c == '{') { brace_depth++; current += c; continue; }
  695. if (c == '}') {
  696. brace_depth--;
  697. current += c;
  698. if (brace_depth <= 0) {
  699. brace_depth = 0;
  700. tokens.push_back(current);
  701. current.clear();
  702. }
  703. continue;
  704. }
  705. if (brace_depth > 0) { current += c; continue; }
  706. if (c == ' ' || c == '\t') {
  707. if (!current.empty()) {
  708. tokens.push_back(current);
  709. current.clear();
  710. }
  711. continue;
  712. }
  713. current += c;
  714. }
  715. if (!current.empty()) tokens.push_back(current);
  716. return tokens;
  717. }
  718. // v2.4.4 — offline sub-db placement audit / repair.
  719. //
  720. // `verify-subdbs` is read-only and safe against a running server.
  721. // `reconcile-subdbs --apply` rewrites document placement and REQUIRES the
  722. // service to be stopped: it holds an LMDB write txn over the whole env.
  723. int cmdSubdbs(const std::string& command, const std::vector<std::string>& params) {
  724. std::string env_path, project;
  725. bool apply = false;
  726. bool stamp = false;
  727. for (size_t i = 0; i < params.size(); ++i) {
  728. const std::string& p = params[i];
  729. if (p == "--env" && i + 1 < params.size()) {
  730. env_path = params[++i];
  731. } else if (p == "--project" && i + 1 < params.size()) {
  732. project = params[++i];
  733. } else if (p == "--apply") {
  734. apply = true;
  735. } else if (p == "--stamp-identity") {
  736. stamp = true;
  737. } else {
  738. printError("unknown argument: " + p);
  739. return 2;
  740. }
  741. }
  742. if (env_path.empty()) {
  743. printError("--env <path> is required (e.g. "
  744. "/var/lib/smartbotic-database/projects/default/env)");
  745. return 2;
  746. }
  747. if (command == "verify-subdbs" && apply) {
  748. printError("--apply is not valid for verify-subdbs; use reconcile-subdbs");
  749. return 2;
  750. }
  751. try {
  752. auto report = smartbotic::db::storage::audit(env_path, project);
  753. smartbotic::db::storage::print_audit(report);
  754. if (command == "verify-subdbs") {
  755. return report.misplaced.empty() ? 0 : 1;
  756. }
  757. if (!apply) {
  758. std::cout << "\n" << C_YELLOW << "Dry run." << C_RESET
  759. << " Re-run with --apply to perform the repair.\n"
  760. << C_DIM
  761. << "Stop the service first, and take a backup: the repair "
  762. "rewrites document placement in place.\n"
  763. << C_RESET;
  764. return report.misplaced.empty() ? 0 : 1;
  765. }
  766. if (stamp) {
  767. std::cout << "\n" << C_YELLOW << "--stamp-identity:" << C_RESET
  768. << " writing sentinels. This REQUIRES the server binary to be"
  769. " v2.4.4 or newer;\n earlier builds do not skip the sentinel"
  770. " and will fail on scan.\n";
  771. }
  772. std::cout << "\nApplying...\n";
  773. auto res = smartbotic::db::storage::repair(env_path, report, stamp);
  774. std::cout << C_GREEN << "done." << C_RESET
  775. << " moved=" << res.moved
  776. << " quarantined=" << res.quarantined
  777. << " stamped=" << res.stamped << "\n";
  778. for (const auto& e : res.errors) {
  779. std::cerr << C_RED << " warn: " << C_RESET << e << "\n";
  780. }
  781. return res.errors.empty() ? 0 : 1;
  782. } catch (const std::exception& e) {
  783. printError(e.what());
  784. return 1;
  785. }
  786. }
  787. } // anonymous namespace
  788. int main(int argc, char* argv[]) {
  789. Args args;
  790. // Parse flags
  791. std::vector<std::string> positional;
  792. for (int i = 1; i < argc; ++i) {
  793. std::string arg = argv[i];
  794. if (arg == "--address" && i + 1 < argc) {
  795. args.address = argv[++i];
  796. } else if (arg == "--help" || arg == "-h") {
  797. printUsage();
  798. return 0;
  799. } else {
  800. positional.push_back(arg);
  801. }
  802. }
  803. if (!positional.empty()) {
  804. args.command = positional[0];
  805. args.params.assign(positional.begin() + 1, positional.end());
  806. }
  807. // Offline helpers — these don't need a running server, so dispatch
  808. // them before opening the gRPC channel.
  809. if (args.command == "generate-auth-key") {
  810. return cmdGenerateAuthKey();
  811. }
  812. if (args.command == "generate-tls-cert") {
  813. return cmdGenerateTlsCert(args.params);
  814. }
  815. if (args.command == "verify-subdbs" || args.command == "reconcile-subdbs") {
  816. return cmdSubdbs(args.command, args.params);
  817. }
  818. // Connect
  819. smartbotic::database::Client client({.address = args.address});
  820. client.connect();
  821. // Scriptable mode: single command
  822. if (!args.command.empty()) {
  823. return execCommand(client, args.command, args.params) ? 0 : 1;
  824. }
  825. // Interactive mode
  826. std::cout << C_BOLD << "smartbotic-db-cli" << C_RESET
  827. << " connected to " << C_CYAN << args.address << C_RESET << "\n"
  828. << C_DIM << "Type 'help' for commands, 'exit' to quit." << C_RESET << "\n";
  829. std::string line;
  830. while (true) {
  831. std::cout << C_YELLOW << "> " << C_RESET;
  832. if (!std::getline(std::cin, line)) break;
  833. // Trim
  834. auto start = line.find_first_not_of(" \t");
  835. if (start == std::string::npos) continue;
  836. line = line.substr(start);
  837. if (line == "exit" || line == "quit" || line == "q") break;
  838. if (line.empty()) continue;
  839. auto tokens = tokenize(line);
  840. if (tokens.empty()) continue;
  841. auto cmd = tokens[0];
  842. std::vector<std::string> params(tokens.begin() + 1, tokens.end());
  843. execCommand(client, cmd, params);
  844. }
  845. return 0;
  846. }