main.cpp 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321
  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 <algorithm>
  18. #include <cerrno>
  19. #include <cstdio>
  20. #include <cstring>
  21. #include <fstream>
  22. #include <iostream>
  23. #include <sstream>
  24. #include <string>
  25. #include <vector>
  26. using json = nlohmann::json;
  27. namespace {
  28. struct Args {
  29. std::string address = "localhost:9004";
  30. // v2.11.0 T6b — needed to exercise relation management against a
  31. // non-default project from the CLI. Empty means the client's own
  32. // "default" (unchanged behaviour for every existing command).
  33. std::string project;
  34. std::string command;
  35. std::vector<std::string> params;
  36. };
  37. // ANSI colors
  38. constexpr auto C_RESET = "\033[0m";
  39. constexpr auto C_BOLD = "\033[1m";
  40. constexpr auto C_DIM = "\033[2m";
  41. constexpr auto C_CYAN = "\033[36m";
  42. constexpr auto C_GREEN = "\033[32m";
  43. constexpr auto C_RED = "\033[31m";
  44. constexpr auto C_YELLOW = "\033[33m";
  45. void printJson(const json& j) {
  46. std::cout << j.dump(2) << "\n";
  47. }
  48. void printError(const std::string& msg) {
  49. std::cerr << C_RED << "error: " << C_RESET << msg << "\n";
  50. }
  51. void printUsage() {
  52. std::cout << C_BOLD << "smartbotic-db-cli" << C_RESET << " — admin tool for smartbotic-database\n\n"
  53. << C_BOLD << "Usage:" << C_RESET << "\n"
  54. << " smartbotic-db-cli [--address HOST:PORT] <command> [args...]\n"
  55. << " smartbotic-db-cli [--address HOST:PORT] " << C_DIM << "# interactive mode" << C_RESET << "\n\n"
  56. << C_BOLD << "Commands:" << C_RESET << "\n"
  57. << " " << C_CYAN << "collections" << C_RESET << " List all collections\n"
  58. << " " << C_CYAN << "info" << C_RESET << " <collection> Collection info\n"
  59. << " " << C_CYAN << "find" << C_RESET << " <collection> List documents\n"
  60. << " " << C_DIM << "[--limit N] [--offset N] [--exists FIELD] [--eq FIELD VALUE]" << C_RESET << "\n"
  61. << " " << C_CYAN << "get" << C_RESET << " <collection> <id> Get a document\n"
  62. << " " << C_CYAN << "upsert" << C_RESET << " <collection> <id> '<json>' Insert or update\n"
  63. << " " << C_CYAN << "remove" << C_RESET << " <collection> <id> Delete a document\n"
  64. << " " << C_CYAN << "count" << C_RESET << " <collection> Count documents\n"
  65. << " " << C_CYAN << "lock" << C_RESET << " Lock database (read-only)\n"
  66. << " " << C_CYAN << "unlock" << C_RESET << " Unlock database (accept writes)\n"
  67. << " " << C_CYAN << "status" << C_RESET << " Show read-only + recovery status\n"
  68. << C_BOLD << " Access policy (v2.7.0+)" << C_RESET << "\n"
  69. << " " << C_CYAN << "security" << C_RESET << " [project] Show whether a project enforces policy\n"
  70. << " " << C_CYAN << "indexes" << C_RESET << " <collection>"
  71. << " list secondary indexes\n"
  72. << " " << C_CYAN << "index-create" << C_RESET << " <collection> <field>"
  73. << " declare an index and backfill it\n"
  74. << " " << C_DIM << "[--unique] also enforce uniqueness; refused, with examples, if duplicates exist"
  75. << C_RESET << "\n"
  76. << " " << C_CYAN << "index-drop" << C_RESET << " <collection> <field>"
  77. << " remove an index\n"
  78. << " " << C_CYAN << "index-values" << C_RESET << " <coll> <field> [n] [asc|desc]"
  79. << " distinct values + counts\n"
  80. << C_BOLD << " Relations / referential integrity (v2.11.0+)" << C_RESET << "\n"
  81. << " " << C_CYAN << "relations" << C_RESET
  82. << " List declared relations (every project; --project narrows)\n"
  83. << " " << C_CYAN << "relation" << C_RESET << " <name>"
  84. << " Show one relation's declaration\n"
  85. << " " << C_CYAN << "relation-create" << C_RESET
  86. << " <name> <child> <child_field> <parent> [on_delete] [validate_on_write]\n"
  87. << " " << C_CYAN << "relation-drop" << C_RESET << " <name>\n"
  88. << " " << C_CYAN << "relation-check" << C_RESET << " <name>"
  89. << " Report dangling references (read-only)\n"
  90. << " " << C_CYAN << "configure-relations" << C_RESET
  91. << " <collection> <on|off> Enable/disable enforcement for a collection\n"
  92. << " " << C_CYAN << "describe-delete" << C_RESET << " <collection> <id>"
  93. << " What would happen if this document were deleted\n"
  94. << " " << C_CYAN << "security-set" << C_RESET << " <project> <on|off> [enforce|audit]\n"
  95. << " " << C_CYAN << "policies" << C_RESET << " [project] List principals with a policy\n"
  96. << " " << C_CYAN << "policy" << C_RESET << " <project> <principal> Show one policy\n"
  97. << " " << C_CYAN << "policy-set" << C_RESET << " <project> <principal> '<json>'\n"
  98. << " " << C_CYAN << "policy-rm" << C_RESET << " <project> <principal>\n"
  99. << " " << C_CYAN << "help" << C_RESET << " Show this help\n\n"
  100. << C_BOLD << "Offline helpers" << C_RESET << " " << C_DIM << "(no server connection required)" << C_RESET << ":\n"
  101. << " " << C_CYAN << "generate-auth-key" << C_RESET << " Emit a base64 32-byte API key\n"
  102. << " " << C_CYAN << "generate-tls-cert" << C_RESET << " --bind <addr> Generate a self-signed TLS cert + key\n"
  103. << " " << C_DIM << "[--out-cert PATH] [--out-key PATH] [--days N]" << C_RESET << "\n"
  104. << " " << C_CYAN << "verify-subdbs" << C_RESET << " --env <path> Report documents filed under the wrong collection\n"
  105. << " " << C_DIM << "[--project NAME] read-only; safe against a running server" << C_RESET << "\n"
  106. << " " << C_CYAN << "reconcile-subdbs" << C_RESET << " --env <path> Repair misfiled documents (dry run by default)\n"
  107. << " " << C_DIM << "[--project NAME] [--apply] STOP THE SERVICE and back up before --apply" << C_RESET << "\n\n"
  108. << C_BOLD << "Options:" << C_RESET << "\n"
  109. << " --address HOST:PORT Database address (default: localhost:9004)\n"
  110. << " --project NAME Operate as this project namespace (default: default)\n"
  111. << " --version Print version and build commit, then exit\n";
  112. }
  113. // ---------------------------------------------------------------------------
  114. // v2.4 Stage F: offline helpers (no server connection required)
  115. // ---------------------------------------------------------------------------
  116. // Base64-encode raw bytes. Uses OpenSSL's EVP_EncodeBlock which emits the
  117. // standard base64 alphabet (no newlines) and pads with '='.
  118. std::string base64Encode(const unsigned char* data, size_t len) {
  119. if (len == 0) return {};
  120. // EVP_EncodeBlock writes ((len + 2) / 3) * 4 bytes + a NUL terminator.
  121. const size_t out_len = 4 * ((len + 2) / 3);
  122. std::string out(out_len, '\0');
  123. int written = EVP_EncodeBlock(
  124. reinterpret_cast<unsigned char*>(out.data()),
  125. data, static_cast<int>(len));
  126. if (written < 0) return {};
  127. out.resize(static_cast<size_t>(written));
  128. return out;
  129. }
  130. // Pull 32 cryptographically random bytes from getentropy() (preferred) or
  131. // /dev/urandom (fallback). Returns true on success.
  132. bool fillRandomBytes(unsigned char* buf, size_t len) {
  133. // getentropy() is limited to 256 bytes per call; our use-case is 32.
  134. if (len <= 256) {
  135. if (getentropy(buf, len) == 0) return true;
  136. }
  137. // Fallback: /dev/urandom.
  138. int fd = ::open("/dev/urandom", O_RDONLY | O_CLOEXEC);
  139. if (fd < 0) return false;
  140. size_t got = 0;
  141. while (got < len) {
  142. ssize_t n = ::read(fd, buf + got, len - got);
  143. if (n <= 0) {
  144. if (errno == EINTR) continue;
  145. ::close(fd);
  146. return false;
  147. }
  148. got += static_cast<size_t>(n);
  149. }
  150. ::close(fd);
  151. return true;
  152. }
  153. int cmdGenerateAuthKey() {
  154. unsigned char key[32];
  155. if (!fillRandomBytes(key, sizeof(key))) {
  156. std::fprintf(stderr, "error: failed to obtain entropy for key\n");
  157. return 1;
  158. }
  159. auto encoded = base64Encode(key, sizeof(key));
  160. if (encoded.empty()) {
  161. std::fprintf(stderr, "error: base64 encoding failed\n");
  162. return 1;
  163. }
  164. std::cout << encoded << "\n";
  165. return 0;
  166. }
  167. namespace {
  168. // Print the topmost OpenSSL error to stderr with a prefix.
  169. void printOpenSslError(const char* prefix) {
  170. unsigned long e = ERR_get_error();
  171. char buf[256] = {0};
  172. if (e != 0) {
  173. ERR_error_string_n(e, buf, sizeof(buf));
  174. std::fprintf(stderr, "error: %s: %s\n", prefix, buf);
  175. } else {
  176. std::fprintf(stderr, "error: %s\n", prefix);
  177. }
  178. }
  179. // Returns true if `s` parses as an IPv4 or IPv6 literal.
  180. bool looksLikeIp(const std::string& s) {
  181. unsigned char buf[16];
  182. if (inet_pton(AF_INET, s.c_str(), buf) == 1) return true;
  183. if (inet_pton(AF_INET6, s.c_str(), buf) == 1) return true;
  184. return false;
  185. }
  186. // Generate an RSA private key as an EVP_PKEY using the modern (3.x) API.
  187. EVP_PKEY* generateRsaKey(int bits) {
  188. EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr);
  189. if (!ctx) return nullptr;
  190. EVP_PKEY* pkey = nullptr;
  191. if (EVP_PKEY_keygen_init(ctx) <= 0) {
  192. EVP_PKEY_CTX_free(ctx);
  193. return nullptr;
  194. }
  195. if (EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits) <= 0) {
  196. EVP_PKEY_CTX_free(ctx);
  197. return nullptr;
  198. }
  199. if (EVP_PKEY_keygen(ctx, &pkey) <= 0) {
  200. EVP_PKEY_CTX_free(ctx);
  201. return nullptr;
  202. }
  203. EVP_PKEY_CTX_free(ctx);
  204. return pkey;
  205. }
  206. // Write a string to disk with the given file mode. Truncates existing files.
  207. bool writeFileWithMode(const std::string& path, const std::string& contents, mode_t mode) {
  208. int fd = ::open(path.c_str(),
  209. O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC,
  210. mode);
  211. if (fd < 0) {
  212. std::fprintf(stderr, "error: open %s: %s\n", path.c_str(), std::strerror(errno));
  213. return false;
  214. }
  215. // Re-assert mode in case the file pre-existed with a wider mode and
  216. // O_CREAT was a no-op (open(2) only applies the mode on create).
  217. if (fchmod(fd, mode) != 0) {
  218. std::fprintf(stderr, "error: fchmod %s: %s\n", path.c_str(), std::strerror(errno));
  219. ::close(fd);
  220. return false;
  221. }
  222. size_t off = 0;
  223. while (off < contents.size()) {
  224. ssize_t n = ::write(fd, contents.data() + off, contents.size() - off);
  225. if (n <= 0) {
  226. if (errno == EINTR) continue;
  227. std::fprintf(stderr, "error: write %s: %s\n", path.c_str(), std::strerror(errno));
  228. ::close(fd);
  229. return false;
  230. }
  231. off += static_cast<size_t>(n);
  232. }
  233. if (::close(fd) != 0) {
  234. std::fprintf(stderr, "error: close %s: %s\n", path.c_str(), std::strerror(errno));
  235. return false;
  236. }
  237. return true;
  238. }
  239. // Serialize an X509* to a PEM string.
  240. std::string pemEncodeCert(X509* cert) {
  241. BIO* bio = BIO_new(BIO_s_mem());
  242. if (!bio) return {};
  243. if (PEM_write_bio_X509(bio, cert) != 1) {
  244. BIO_free(bio);
  245. return {};
  246. }
  247. BUF_MEM* mem = nullptr;
  248. BIO_get_mem_ptr(bio, &mem);
  249. std::string out(mem->data, mem->length);
  250. BIO_free(bio);
  251. return out;
  252. }
  253. // Serialize an EVP_PKEY* to an unencrypted PEM string (PKCS#8 format via
  254. // PEM_write_bio_PrivateKey).
  255. std::string pemEncodeKey(EVP_PKEY* key) {
  256. BIO* bio = BIO_new(BIO_s_mem());
  257. if (!bio) return {};
  258. if (PEM_write_bio_PrivateKey(bio, key, nullptr, nullptr, 0, nullptr, nullptr) != 1) {
  259. BIO_free(bio);
  260. return {};
  261. }
  262. BUF_MEM* mem = nullptr;
  263. BIO_get_mem_ptr(bio, &mem);
  264. std::string out(mem->data, mem->length);
  265. BIO_free(bio);
  266. return out;
  267. }
  268. } // anonymous namespace
  269. int cmdGenerateTlsCert(const std::vector<std::string>& params) {
  270. std::string bind;
  271. std::string out_cert = "./server.pem";
  272. std::string out_key = "./server.key";
  273. int days = 3650;
  274. for (size_t i = 0; i < params.size(); ++i) {
  275. const auto& p = params[i];
  276. auto need = [&](const char* flag) -> const std::string* {
  277. if (i + 1 >= params.size()) {
  278. std::fprintf(stderr, "error: %s requires a value\n", flag);
  279. return nullptr;
  280. }
  281. return &params[++i];
  282. };
  283. if (p == "--bind") {
  284. auto* v = need("--bind"); if (!v) return 2; bind = *v;
  285. } else if (p == "--out-cert") {
  286. auto* v = need("--out-cert"); if (!v) return 2; out_cert = *v;
  287. } else if (p == "--out-key") {
  288. auto* v = need("--out-key"); if (!v) return 2; out_key = *v;
  289. } else if (p == "--days") {
  290. auto* v = need("--days"); if (!v) return 2;
  291. try { days = std::stoi(*v); }
  292. catch (...) {
  293. std::fprintf(stderr, "error: --days must be an integer\n");
  294. return 2;
  295. }
  296. if (days <= 0) {
  297. std::fprintf(stderr, "error: --days must be > 0\n");
  298. return 2;
  299. }
  300. } else {
  301. std::fprintf(stderr, "error: unknown argument: %s\n", p.c_str());
  302. return 2;
  303. }
  304. }
  305. if (bind.empty()) {
  306. std::fprintf(stderr,
  307. "usage: generate-tls-cert --bind <addr> "
  308. "[--out-cert PATH] [--out-key PATH] [--days N]\n");
  309. return 2;
  310. }
  311. // 1. RSA 4096-bit key.
  312. EVP_PKEY* pkey = generateRsaKey(4096);
  313. if (!pkey) {
  314. printOpenSslError("RSA key generation failed");
  315. return 1;
  316. }
  317. // 2. X.509 certificate.
  318. X509* cert = X509_new();
  319. if (!cert) {
  320. printOpenSslError("X509_new failed");
  321. EVP_PKEY_free(pkey);
  322. return 1;
  323. }
  324. // Version 3 (the integer field encodes v3 as 2).
  325. if (X509_set_version(cert, 2) != 1) {
  326. printOpenSslError("X509_set_version failed");
  327. X509_free(cert); EVP_PKEY_free(pkey);
  328. return 1;
  329. }
  330. // Random 64-bit serial number.
  331. {
  332. unsigned char serial_bytes[8];
  333. if (RAND_bytes(serial_bytes, sizeof(serial_bytes)) != 1) {
  334. printOpenSslError("RAND_bytes for serial failed");
  335. X509_free(cert); EVP_PKEY_free(pkey);
  336. return 1;
  337. }
  338. // Mask the top bit so the BIGNUM is positive.
  339. serial_bytes[0] &= 0x7F;
  340. BIGNUM* bn = BN_bin2bn(serial_bytes, sizeof(serial_bytes), nullptr);
  341. if (!bn) {
  342. printOpenSslError("BN_bin2bn failed");
  343. X509_free(cert); EVP_PKEY_free(pkey);
  344. return 1;
  345. }
  346. ASN1_INTEGER* ai = BN_to_ASN1_INTEGER(bn, nullptr);
  347. BN_free(bn);
  348. if (!ai) {
  349. printOpenSslError("BN_to_ASN1_INTEGER failed");
  350. X509_free(cert); EVP_PKEY_free(pkey);
  351. return 1;
  352. }
  353. if (X509_set_serialNumber(cert, ai) != 1) {
  354. printOpenSslError("X509_set_serialNumber failed");
  355. ASN1_INTEGER_free(ai);
  356. X509_free(cert); EVP_PKEY_free(pkey);
  357. return 1;
  358. }
  359. ASN1_INTEGER_free(ai);
  360. }
  361. // Validity period.
  362. if (!X509_gmtime_adj(X509_get_notBefore(cert), 0)) {
  363. printOpenSslError("X509_gmtime_adj(notBefore) failed");
  364. X509_free(cert); EVP_PKEY_free(pkey);
  365. return 1;
  366. }
  367. long seconds = static_cast<long>(days) * 24L * 60L * 60L;
  368. if (!X509_gmtime_adj(X509_get_notAfter(cert), seconds)) {
  369. printOpenSslError("X509_gmtime_adj(notAfter) failed");
  370. X509_free(cert); EVP_PKEY_free(pkey);
  371. return 1;
  372. }
  373. // Public key.
  374. if (X509_set_pubkey(cert, pkey) != 1) {
  375. printOpenSslError("X509_set_pubkey failed");
  376. X509_free(cert); EVP_PKEY_free(pkey);
  377. return 1;
  378. }
  379. // Subject + issuer name (self-signed, so identical).
  380. X509_NAME* name = X509_get_subject_name(cert);
  381. if (X509_NAME_add_entry_by_txt(
  382. name, "CN", MBSTRING_UTF8,
  383. reinterpret_cast<const unsigned char*>(bind.c_str()),
  384. -1, -1, 0) != 1) {
  385. printOpenSslError("X509_NAME_add_entry_by_txt(CN) failed");
  386. X509_free(cert); EVP_PKEY_free(pkey);
  387. return 1;
  388. }
  389. if (X509_set_issuer_name(cert, name) != 1) {
  390. printOpenSslError("X509_set_issuer_name failed");
  391. X509_free(cert); EVP_PKEY_free(pkey);
  392. return 1;
  393. }
  394. // SubjectAltName.
  395. {
  396. std::string san = "DNS:localhost,IP:127.0.0.1";
  397. if (bind != "localhost" && bind != "127.0.0.1") {
  398. san += ',';
  399. san += looksLikeIp(bind) ? "IP:" : "DNS:";
  400. san += bind;
  401. }
  402. X509_EXTENSION* ext = X509V3_EXT_conf_nid(
  403. nullptr, nullptr, NID_subject_alt_name, san.c_str());
  404. if (!ext) {
  405. printOpenSslError("X509V3_EXT_conf_nid(SAN) failed");
  406. X509_free(cert); EVP_PKEY_free(pkey);
  407. return 1;
  408. }
  409. if (X509_add_ext(cert, ext, -1) != 1) {
  410. printOpenSslError("X509_add_ext(SAN) failed");
  411. X509_EXTENSION_free(ext);
  412. X509_free(cert); EVP_PKEY_free(pkey);
  413. return 1;
  414. }
  415. X509_EXTENSION_free(ext);
  416. }
  417. // basicConstraints CA:FALSE — a server leaf cert, not a CA.
  418. {
  419. X509_EXTENSION* ext = X509V3_EXT_conf_nid(
  420. nullptr, nullptr, NID_basic_constraints, "critical,CA:FALSE");
  421. if (ext) {
  422. X509_add_ext(cert, ext, -1);
  423. X509_EXTENSION_free(ext);
  424. }
  425. }
  426. // Sign with SHA-256.
  427. if (X509_sign(cert, pkey, EVP_sha256()) == 0) {
  428. printOpenSslError("X509_sign failed");
  429. X509_free(cert); EVP_PKEY_free(pkey);
  430. return 1;
  431. }
  432. // Serialize.
  433. std::string cert_pem = pemEncodeCert(cert);
  434. std::string key_pem = pemEncodeKey(pkey);
  435. X509_free(cert);
  436. EVP_PKEY_free(pkey);
  437. if (cert_pem.empty() || key_pem.empty()) {
  438. std::fprintf(stderr, "error: PEM encoding failed\n");
  439. return 1;
  440. }
  441. // Write key first (0600), then cert (0644). If either fails, the other
  442. // may have been written — that's acceptable; the operator will see the
  443. // error and retry.
  444. if (!writeFileWithMode(out_key, key_pem, 0600)) return 1;
  445. if (!writeFileWithMode(out_cert, cert_pem, 0644)) return 1;
  446. std::cout << "Wrote cert: " << out_cert << "\n"
  447. << "Wrote key: " << out_key << "\n";
  448. return 0;
  449. }
  450. bool execCommand(smartbotic::database::Client& client,
  451. const std::string& cmd, const std::vector<std::string>& params,
  452. // v2.11.1 — empty when the operator passed no --project, which
  453. // for `relations` means "every project" rather than "default".
  454. const std::string& explicitProject) {
  455. try {
  456. if (cmd == "collections") {
  457. auto collections = client.listCollections();
  458. std::cout << C_BOLD << "Collections:" << C_RESET << "\n";
  459. for (const auto& c : collections) {
  460. auto info = client.getCollectionInfo(c);
  461. int64_t count = 0;
  462. if (info) count = info->documentCount;
  463. std::cout << " " << C_CYAN << c << C_RESET
  464. << C_DIM << " (" << count << " docs)" << C_RESET << "\n";
  465. }
  466. return true;
  467. }
  468. if (cmd == "info") {
  469. if (params.empty()) { printError("usage: info <collection>"); return false; }
  470. auto info = client.getCollectionInfo(params[0]);
  471. if (!info) { printError("collection not found: " + params[0]); return false; }
  472. std::cout << C_BOLD << params[0] << C_RESET << ":\n"
  473. << " documents: " << info->documentCount << "\n"
  474. << " size: " << info->sizeBytes << " bytes\n"
  475. << " encrypted: " << (info->encrypted ? "yes" : "no") << "\n"
  476. << " max_versions: " << info->maxVersions << "\n";
  477. if (info->defaultTtlSeconds > 0)
  478. std::cout << " ttl: " << info->defaultTtlSeconds << "s\n";
  479. return true;
  480. }
  481. if (cmd == "find") {
  482. if (params.empty()) {
  483. printError("usage: find <collection> [--limit N] [--offset N] [--exists FIELD] [--eq FIELD VALUE]");
  484. return false;
  485. }
  486. smartbotic::database::Client::QueryOptions opts;
  487. opts.limit = 100;
  488. for (size_t i = 1; i < params.size(); ++i) {
  489. if (params[i] == "--limit" && i + 1 < params.size()) {
  490. opts.limit = static_cast<uint32_t>(std::stoul(params[++i]));
  491. } else if (params[i] == "--offset" && i + 1 < params.size()) {
  492. // v2.11.1 — paging from the CLI. Without it, walking a
  493. // collection larger than one page meant deleting as you go,
  494. // which is not available when you only want to read.
  495. opts.offset = static_cast<uint32_t>(std::stoul(params[++i]));
  496. } else if (params[i] == "--eq" && i + 2 < params.size()) {
  497. // v2.11.1 — the commonest filter, so cleanup and inspection
  498. // do not need a client program. The value is taken as JSON
  499. // when it parses as JSON and as a string otherwise, so
  500. // --eq status completed and --eq attempts 3 both work.
  501. const std::string field = params[++i];
  502. const std::string raw = params[++i];
  503. nlohmann::json val;
  504. try {
  505. val = nlohmann::json::parse(raw);
  506. } catch (const std::exception&) {
  507. val = raw;
  508. }
  509. opts.filters.emplace_back(field, smartbotic::database::Client::FilterOp::EQ, val);
  510. } else if (params[i] == "--exists" && i + 1 < params.size()) {
  511. opts.filters.emplace_back(params[++i], smartbotic::database::Client::FilterOp::EXISTS, true);
  512. }
  513. }
  514. auto docs = client.find(params[0], opts);
  515. std::cout << C_DIM << "(" << docs.size() << " documents)" << C_RESET << "\n";
  516. for (const auto& doc : docs) {
  517. auto id = doc.value("_id", "");
  518. // Print compact summary line
  519. std::cout << C_GREEN << id << C_RESET;
  520. // Show a few key fields
  521. for (const auto& [k, v] : doc.items()) {
  522. if (k == "_id" || k == "_created_at" || k == "_updated_at") continue;
  523. auto val = v.dump();
  524. if (val.size() > 60) val = val.substr(0, 57) + "...";
  525. std::cout << " " << C_DIM << k << "=" << C_RESET << val;
  526. // Limit to 3 fields per line
  527. static int field_count = 0;
  528. if (++field_count >= 3) { field_count = 0; break; }
  529. }
  530. std::cout << "\n";
  531. }
  532. return true;
  533. }
  534. if (cmd == "get") {
  535. if (params.size() < 2) { printError("usage: get <collection> <id>"); return false; }
  536. auto doc = client.get(params[0], params[1]);
  537. if (!doc) { printError("not found: " + params[0] + "/" + params[1]); return false; }
  538. printJson(*doc);
  539. return true;
  540. }
  541. if (cmd == "upsert") {
  542. if (params.size() < 3) { printError("usage: upsert <collection> <id> '<json>'"); return false; }
  543. auto data = json::parse(params[2], nullptr, false);
  544. if (!data.is_object()) { printError("invalid JSON: " + params[2]); return false; }
  545. client.upsert(params[0], data, params[1]);
  546. std::cout << C_GREEN << "ok" << C_RESET << " " << params[0] << "/" << params[1] << "\n";
  547. return true;
  548. }
  549. if (cmd == "remove" || cmd == "delete") {
  550. if (params.size() < 2) { printError("usage: remove <collection> <id>"); return false; }
  551. // v2.11.0 T6b — the return value used to be discarded and "ok
  552. // removed" printed unconditionally. That was merely sloppy
  553. // before referential integrity: now a delete can be REFUSED (a
  554. // relation with on_delete=restrict/no_action still has children
  555. // referencing it), and the server reports that as a real error,
  556. // not deleted=false. Report the actual outcome, and surface the
  557. // server's message on refusal so an operator learns what
  558. // blocked them rather than being told it worked.
  559. std::string err;
  560. bool deleted = client.remove(params[0], params[1], err);
  561. if (!err.empty()) {
  562. printError("remove " + params[0] + "/" + params[1] + ": " + err);
  563. return false;
  564. }
  565. if (!deleted) {
  566. std::cout << C_YELLOW << "not found" << C_RESET << " "
  567. << params[0] << "/" << params[1] << "\n";
  568. return true;
  569. }
  570. std::cout << C_GREEN << "ok" << C_RESET << " removed " << params[0] << "/" << params[1] << "\n";
  571. return true;
  572. }
  573. if (cmd == "count") {
  574. if (params.empty()) { printError("usage: count <collection>"); return false; }
  575. auto info = client.getCollectionInfo(params[0]);
  576. if (!info) { printError("collection not found: " + params[0]); return false; }
  577. std::cout << info->documentCount << "\n";
  578. return true;
  579. }
  580. // ===== v2.9.0 secondary indexes =====
  581. //
  582. // Declaration is explicit because an index costs write throughput and is
  583. // not always a win: on a low-cardinality field the planner will decline to
  584. // use it, since reading the index and then fetching most of the collection
  585. // by id loses to scanning. `indexes` reports distinct values precisely so
  586. // an operator can see that coming.
  587. if (cmd == "indexes") {
  588. if (params.empty()) { printError("usage: indexes <collection>"); return false; }
  589. std::vector<bool> uniqueFlags;
  590. auto list = client.listIndexes(params[0], uniqueFlags);
  591. if (list.empty()) {
  592. std::cout << "no indexes on " << params[0] << "\n";
  593. return true;
  594. }
  595. std::cout << C_BOLD << "field distinct entries unique"
  596. << C_RESET << "\n";
  597. for (size_t idx = 0; idx < list.size(); ++idx) {
  598. const auto& i = list[idx];
  599. const bool unique = idx < uniqueFlags.size() && uniqueFlags[idx];
  600. std::cout << " " << i.field
  601. << std::string(i.field.size() < 29 ? 29 - i.field.size() : 1, ' ')
  602. << i.distinctValues
  603. << std::string(std::to_string(i.distinctValues).size() < 13
  604. ? 13 - std::to_string(i.distinctValues).size()
  605. : 1, ' ')
  606. << i.entries
  607. << std::string(std::to_string(i.entries).size() < 9
  608. ? 9 - std::to_string(i.entries).size()
  609. : 1, ' ')
  610. << (unique ? "yes" : "no") << "\n";
  611. }
  612. return true;
  613. }
  614. if (cmd == "index-values") {
  615. if (params.size() < 2) {
  616. printError("usage: index-values <collection> <field> [limit] [asc|desc]");
  617. return false;
  618. }
  619. const uint32_t limit = params.size() > 2 ? std::stoul(params[2]) : 20;
  620. const bool asc = params.size() > 3 ? (params[3] != "desc") : true;
  621. auto vals = client.indexValues(params[0], params[1], limit, asc);
  622. if (vals.empty()) {
  623. std::cout << "no values (is " << params[1] << " indexed?)\n";
  624. return true;
  625. }
  626. std::cout << C_BOLD << "rows value" << C_RESET << "\n";
  627. for (const auto& v : vals) {
  628. const std::string c = std::to_string(v.count);
  629. std::cout << " " << c
  630. << std::string(c.size() < 9 ? 9 - c.size() : 1, ' ')
  631. << v.value.dump() << "\n";
  632. }
  633. return true;
  634. }
  635. if (cmd == "index-create") {
  636. if (params.size() < 2) {
  637. printError("usage: index-create <collection> <field> [--unique]");
  638. return false;
  639. }
  640. const bool unique = std::find(params.begin(), params.end(), "--unique") != params.end();
  641. if (unique) {
  642. // v2.11.0 T11 — a duplicate-refusal is not a generic failure:
  643. // surface the examples so the operator can go fix the data
  644. // rather than guess what "could not create the index" meant.
  645. auto result = client.createUniqueIndex(params[0], params[1]);
  646. if (!result.success) {
  647. printError(result.error);
  648. if (!result.duplicateExamples.empty()) {
  649. std::cout << " colliding document ids: ";
  650. for (size_t i = 0; i < result.duplicateExamples.size(); ++i) {
  651. if (i) std::cout << ", ";
  652. std::cout << result.duplicateExamples[i];
  653. }
  654. std::cout << "\n";
  655. }
  656. return false;
  657. }
  658. std::cout << "indexed " << result.rowsIndexed << " existing row(s) on "
  659. << params[0] << "#" << params[1] << " (unique)\n";
  660. return true;
  661. }
  662. uint64_t rows = 0;
  663. if (!client.createIndex(params[0], params[1], rows)) {
  664. printError("could not create the index (see the service log)");
  665. return false;
  666. }
  667. std::cout << "indexed " << rows << " existing row(s) on "
  668. << params[0] << "#" << params[1] << "\n";
  669. return true;
  670. }
  671. if (cmd == "index-drop") {
  672. if (params.size() < 2) {
  673. printError("usage: index-drop <collection> <field>");
  674. return false;
  675. }
  676. if (!client.dropIndex(params[0], params[1])) {
  677. printError("could not drop the index (see the service log)");
  678. return false;
  679. }
  680. std::cout << "dropped " << params[0] << "#" << params[1] << "\n";
  681. return true;
  682. }
  683. // ===== v2.11.0 T6b — relations (referential integrity) =====
  684. //
  685. // Declaration is admin-only: `_relations` is a system collection and a
  686. // relation names another collection's schema, which is not ordinary
  687. // per-collection write access. Follows the `indexes` output shape.
  688. if (cmd == "relations") {
  689. // v2.11.1 — an operator asking "what is enforced on this database"
  690. // means the whole database. Scoping this to the client's own project
  691. // made it print "no relations declared" on an instance with active
  692. // enforcement in another project, while `relation <name>` (never
  693. // project-scoped) happily showed that same relation. --project
  694. // narrows it back down.
  695. auto list = client.listRelations(explicitProject);
  696. if (list.empty()) {
  697. std::cout << (explicitProject.empty()
  698. ? "no relations declared in any project\n"
  699. : "no relations declared in project '" +
  700. explicitProject + "'\n");
  701. return true;
  702. }
  703. std::cout << C_BOLD << "name child.field -> parent on_delete enforced-at-write"
  704. << C_RESET << "\n";
  705. for (const auto& r : list) {
  706. std::cout << " " << C_CYAN << r.name << C_RESET
  707. << std::string(r.name.size() < 19 ? 19 - r.name.size() : 1, ' ')
  708. << r.child << "." << r.childField << " -> " << r.parent
  709. << " " << r.onDelete
  710. << (r.validateOnWrite ? " (validate_on_write)" : "") << "\n";
  711. }
  712. return true;
  713. }
  714. if (cmd == "relation") {
  715. if (params.empty()) { printError("usage: relation <name>"); return false; }
  716. auto r = client.getRelationInfo(params[0]);
  717. if (!r) { printError("relation not found: " + params[0]); return false; }
  718. std::cout << C_BOLD << r->name << C_RESET << ":\n"
  719. << " child: " << r->child << "\n"
  720. << " child_field: " << r->childField << "\n"
  721. << " parent: " << r->parent << "\n"
  722. << " on_delete: " << r->onDelete << "\n"
  723. << " validate_on_write: " << (r->validateOnWrite ? "yes" : "no") << "\n";
  724. return true;
  725. }
  726. if (cmd == "relation-create") {
  727. if (params.size() < 4) {
  728. printError("usage: relation-create <name> <child> <child_field> <parent> "
  729. "[on_delete] [validate_on_write]\n"
  730. " on_delete: restrict (default) | cascade | set_null | no_action\n"
  731. " note (v2.11.0+): cascade deletes the referencing document "
  732. "(scalar reference) or pulls the id from the array and keeps the "
  733. "document (array reference - cascade and set_null are the same "
  734. "for arrays); set_null nulls the scalar field. no_action permits "
  735. "the delete and leaves the reference dangling.");
  736. return false;
  737. }
  738. const std::string onDelete = params.size() > 4 ? params[4] : "restrict";
  739. const bool validateOnWrite = params.size() > 5
  740. && (params[5] == "true" || params[5] == "1" || params[5] == "yes");
  741. // v2.11.0 T7 - the server backfills the reverse index over rows
  742. // already in the child collection as part of this call, so
  743. // rowsIndexed reports coverage the same way index-create does.
  744. uint64_t rowsIndexed = 0;
  745. if (!client.createRelation(params[0], params[1], params[2], params[3],
  746. onDelete, validateOnWrite, rowsIndexed)) {
  747. printError("could not create the relation (see the service log)");
  748. return false;
  749. }
  750. std::cout << "declared relation " << params[0] << " (" << params[1] << "."
  751. << params[2] << " -> " << params[3] << ", on_delete=" << onDelete << ")\n"
  752. << "indexed " << rowsIndexed << " existing row(s) in " << params[1] << "\n";
  753. return true;
  754. }
  755. if (cmd == "relation-drop") {
  756. if (params.empty()) { printError("usage: relation-drop <name>"); return false; }
  757. if (!client.dropRelation(params[0])) {
  758. printError("could not drop the relation (see the service log)");
  759. return false;
  760. }
  761. std::cout << "dropped relation " << params[0] << "\n";
  762. return true;
  763. }
  764. // v2.11.0 T7 - "does this relation's reverse index actually match
  765. // live data?" Read-only, changes nothing. Walks the whole reverse
  766. // index (cost is proportional to distinct parents referenced, not to
  767. // the child collection's size) so this is a migration/operator tool,
  768. // not something to run in a loop.
  769. if (cmd == "relation-check") {
  770. if (params.empty()) { printError("usage: relation-check <name>"); return false; }
  771. auto result = client.checkRelation(params[0]);
  772. if (!result.success) {
  773. printError("could not check the relation: " + result.error);
  774. return false;
  775. }
  776. if (result.totalDangling == 0) {
  777. std::cout << C_GREEN << "clean" << C_RESET << " - no dangling references for "
  778. << params[0] << "\n";
  779. return true;
  780. }
  781. std::cout << C_RED << result.totalDangling << " dangling reference(s)" << C_RESET
  782. << " for " << params[0] << ":\n";
  783. for (const auto& d : result.dangling) {
  784. std::cout << " parent " << d.parentId << " does not exist, referenced by "
  785. << d.childCount << " child document(s)";
  786. if (!d.sampleChildIds.empty()) {
  787. std::cout << " (e.g. ";
  788. for (size_t i = 0; i < d.sampleChildIds.size(); ++i) {
  789. if (i) std::cout << ", ";
  790. std::cout << d.sampleChildIds[i];
  791. }
  792. std::cout << ")";
  793. }
  794. std::cout << "\n";
  795. }
  796. if (result.dangling.size() < result.totalDangling) {
  797. std::cout << " ... " << (result.totalDangling - result.dangling.size())
  798. << " more not shown\n";
  799. }
  800. return true;
  801. }
  802. // v2.11.0 T8 — the only reachable path to relations_enforced besides
  803. // grpcurl. A partial update: touches only this one knob.
  804. if (cmd == "configure-relations") {
  805. if (params.size() < 2) {
  806. printError("usage: configure-relations <collection> <on|off>");
  807. return false;
  808. }
  809. if (params[1] != "on" && params[1] != "off") {
  810. printError("expected 'on' or 'off', got: " + params[1]);
  811. return false;
  812. }
  813. const bool enforced = params[1] == "on";
  814. if (!client.setRelationsEnforced(params[0], enforced)) {
  815. printError("could not update relations_enforced (see the service log)");
  816. return false;
  817. }
  818. std::cout << C_GREEN << "ok" << C_RESET << " " << params[0]
  819. << " relations_enforced=" << (enforced ? "on" : "off") << "\n";
  820. return true;
  821. }
  822. // v2.11.0 T5 — DescribeDelete: "what would happen if I deleted this?"
  823. // without deleting anything. A per-collection READ, not admin - any
  824. // caller who can read the collection can ask this. Cheap enough to
  825. // run before every delete: counts come from the reverse index.
  826. if (cmd == "describe-delete") {
  827. if (params.size() < 2) {
  828. printError("usage: describe-delete <collection> <id>");
  829. return false;
  830. }
  831. auto d = client.describeDelete(params[0], params[1]);
  832. if (!d.success) {
  833. printError("could not describe the delete: " + d.error);
  834. return false;
  835. }
  836. if (d.impacts.empty()) {
  837. std::cout << "no relations reference " << params[0] << "/" << params[1]
  838. << " - safe to delete\n";
  839. return true;
  840. }
  841. std::cout << (d.wouldBeBlocked
  842. ? (C_RED + std::string("would be BLOCKED") + C_RESET)
  843. : (C_GREEN + std::string("would proceed") + C_RESET))
  844. << " deleting " << params[0] << "/" << params[1] << ":\n";
  845. for (const auto& imp : d.impacts) {
  846. std::cout << " " << (imp.blocks ? C_RED : C_DIM) << "[" << imp.onDelete << "]"
  847. << C_RESET << " " << imp.relation << ": " << imp.childCount
  848. << " child document(s) in " << imp.childCollection
  849. << " via " << imp.childField;
  850. if (!imp.sampleChildIds.empty()) {
  851. std::cout << " (e.g. ";
  852. for (size_t i = 0; i < imp.sampleChildIds.size(); ++i) {
  853. if (i) std::cout << ", ";
  854. std::cout << imp.sampleChildIds[i];
  855. }
  856. std::cout << ")";
  857. }
  858. std::cout << (imp.blocks ? " BLOCKS" : "") << "\n";
  859. }
  860. return true;
  861. }
  862. // ===== v2.7.0 access policy =====
  863. //
  864. // Policy lives in the `_policies` collection and is managed through the
  865. // ordinary document API, which the server intercepts so edits refresh
  866. // its cache and the `__security__` record goes through the lockout
  867. // guards. These commands are ergonomics over that, not a second
  868. // mechanism - which is why there is no policy RPC to keep in step.
  869. if (cmd == "policies") {
  870. const std::string project = params.empty() ? "default" : params[0];
  871. auto rows = client.find("_policies", smartbotic::database::Client::QueryOptions{.limit = 1000});
  872. std::cout << C_BOLD << "policies in project '" << project << "'" << C_RESET << "\n";
  873. size_t shown = 0;
  874. for (const auto& r : rows) {
  875. const std::string id = r.value("_id", "");
  876. if (id.rfind(project + ":", 0) != 0) continue;
  877. const std::string tail = id.substr(project.size() + 1);
  878. if (tail == "__security__") continue;
  879. std::cout << " " << C_CYAN << tail << C_RESET
  880. << (r.value("admin", false) ? " (admin)" : "") << "\n";
  881. ++shown;
  882. }
  883. if (shown == 0) std::cout << C_DIM << " (none)" << C_RESET << "\n";
  884. return true;
  885. }
  886. if (cmd == "policy") {
  887. if (params.size() < 2) {
  888. printError("usage: policy <project> <principal>");
  889. return false;
  890. }
  891. auto doc = client.get("_policies", params[0] + ":" + params[1]);
  892. if (!doc) { printError("no policy for " + params[0] + ":" + params[1]); return false; }
  893. printJson(*doc);
  894. return true;
  895. }
  896. if (cmd == "policy-set") {
  897. if (params.size() < 3) {
  898. printError("usage: policy-set <project> <principal> '<json>'\n"
  899. " e.g. policy-set acme svc "
  900. "'{\"collections\":{\"users\":{\"read\":true,\"mask\":[\"ssn\"]}}}'\n"
  901. " admin: policy-set acme ops '{\"admin\":true}'");
  902. return false;
  903. }
  904. try {
  905. auto body = json::parse(params[2]);
  906. client.upsert("_policies", body, params[0] + ":" + params[1]);
  907. std::cout << C_GREEN << "ok" << C_RESET << " policy set for "
  908. << params[0] << ":" << params[1] << "\n";
  909. return true;
  910. } catch (const std::exception& e) {
  911. printError(e.what());
  912. return false;
  913. }
  914. }
  915. if (cmd == "policy-rm") {
  916. if (params.size() < 2) { printError("usage: policy-rm <project> <principal>"); return false; }
  917. try {
  918. bool ok = client.remove("_policies", params[0] + ":" + params[1]);
  919. std::cout << (ok ? "removed\n" : "not found\n");
  920. return ok;
  921. } catch (const std::exception& e) {
  922. // The server refuses to remove the last admin of a secured
  923. // project - that refusal is the lockout guard, not an error to
  924. // work around.
  925. printError(e.what());
  926. return false;
  927. }
  928. }
  929. if (cmd == "security") {
  930. const std::string project = params.empty() ? "default" : params[0];
  931. auto doc = client.get("_policies", project + ":__security__");
  932. if (!doc) {
  933. std::cout << "project '" << project << "': security "
  934. << C_GREEN << "disabled" << C_RESET
  935. << C_DIM << " (default - all access allowed)" << C_RESET << "\n";
  936. return true;
  937. }
  938. const bool on = doc->value("enabled", false);
  939. const std::string mode = doc->value("mode", "enforce");
  940. std::cout << "project '" << project << "': security "
  941. << (on ? (mode == "audit" ? C_YELLOW : C_RED) : C_GREEN)
  942. << (on ? (mode == "audit" ? "AUDIT" : "ENFORCED") : "disabled")
  943. << C_RESET << "\n";
  944. if (on && mode == "audit") {
  945. std::cout << C_DIM << " audit mode logs what it would deny and "
  946. "allows the request - watch the service log, then "
  947. "switch to enforce." << C_RESET << "\n";
  948. }
  949. return true;
  950. }
  951. if (cmd == "security-set") {
  952. if (params.size() < 2) {
  953. printError("usage: security-set <project> <on|off> [enforce|audit]\n"
  954. " Enabling is REFUSED unless some policy in the project has "
  955. "admin=true.\n"
  956. " Switch a live project on with 'audit' first.");
  957. return false;
  958. }
  959. const bool on = params[1] == "on" || params[1] == "true";
  960. const std::string mode = params.size() > 2 ? params[2] : "enforce";
  961. if (mode != "enforce" && mode != "audit") {
  962. printError("mode must be 'enforce' or 'audit'");
  963. return false;
  964. }
  965. try {
  966. json body;
  967. body["enabled"] = on;
  968. body["mode"] = mode;
  969. client.upsert("_policies", body, params[0] + ":__security__");
  970. std::cout << C_GREEN << "ok" << C_RESET << " project '" << params[0]
  971. << "' security " << (on ? mode : "disabled") << "\n";
  972. return true;
  973. } catch (const std::exception& e) {
  974. printError(e.what());
  975. return false;
  976. }
  977. }
  978. if (cmd == "lock") {
  979. if (client.setReadOnly(true)) {
  980. std::cout << C_GREEN << "ok" << C_RESET << " Database locked (read-only)\n";
  981. return true;
  982. }
  983. printError("failed to lock database");
  984. return false;
  985. }
  986. if (cmd == "unlock") {
  987. if (client.setReadOnly(false)) {
  988. std::cout << C_GREEN << "ok" << C_RESET << " Database unlocked (writes accepted)\n";
  989. return true;
  990. }
  991. printError("failed to unlock database");
  992. return false;
  993. }
  994. if (cmd == "status") {
  995. auto s = client.getReadOnlyStatus();
  996. std::cout << "Read-only: " << (s.readOnly ? (std::string(C_RED) + "YES" + C_RESET) : "no") << "\n";
  997. if (s.readOnly) {
  998. std::cout << "Reason: " << s.reason << "\n";
  999. }
  1000. std::cout << "Recovery outcome: " << s.recoveryOutcome << "\n";
  1001. if (!s.expectedSnapshot.empty()) {
  1002. std::cout << "Expected snapshot: " << s.expectedSnapshot << "\n";
  1003. }
  1004. if (!s.snapshotUsed.empty()) {
  1005. std::cout << "Snapshot used: " << s.snapshotUsed << "\n";
  1006. }
  1007. if (!s.failureReason.empty()) {
  1008. std::cout << "Failure reason: " << s.failureReason << "\n";
  1009. }
  1010. std::cout << "WAL replayed: " << s.walEntriesReplayed << " entries\n";
  1011. std::cout << "Snapshots tried: " << s.snapshotsAttempted << "\n";
  1012. return true;
  1013. }
  1014. if (cmd == "help" || cmd == "?") {
  1015. printUsage();
  1016. return true;
  1017. }
  1018. printError("unknown command: " + cmd + " (try 'help')");
  1019. return false;
  1020. } catch (const std::exception& e) {
  1021. printError(e.what());
  1022. return false;
  1023. }
  1024. }
  1025. // Tokenize a line, respecting single/double quotes and JSON braces
  1026. std::vector<std::string> tokenize(const std::string& line) {
  1027. std::vector<std::string> tokens;
  1028. std::string current;
  1029. int brace_depth = 0;
  1030. char in_quote = 0;
  1031. for (size_t i = 0; i < line.size(); ++i) {
  1032. char c = line[i];
  1033. if (in_quote) {
  1034. current += c;
  1035. if (c == in_quote && (i == 0 || line[i-1] != '\\')) {
  1036. in_quote = 0;
  1037. // Strip surrounding quotes for simple string tokens
  1038. if (brace_depth == 0 && current.size() >= 2
  1039. && (current.front() == '\'' || current.front() == '"')
  1040. && current.front() == current.back()) {
  1041. current = current.substr(1, current.size() - 2);
  1042. }
  1043. }
  1044. continue;
  1045. }
  1046. if (c == '\'' || c == '"') {
  1047. in_quote = c;
  1048. current += c;
  1049. continue;
  1050. }
  1051. if (c == '{') { brace_depth++; current += c; continue; }
  1052. if (c == '}') {
  1053. brace_depth--;
  1054. current += c;
  1055. if (brace_depth <= 0) {
  1056. brace_depth = 0;
  1057. tokens.push_back(current);
  1058. current.clear();
  1059. }
  1060. continue;
  1061. }
  1062. if (brace_depth > 0) { current += c; continue; }
  1063. if (c == ' ' || c == '\t') {
  1064. if (!current.empty()) {
  1065. tokens.push_back(current);
  1066. current.clear();
  1067. }
  1068. continue;
  1069. }
  1070. current += c;
  1071. }
  1072. if (!current.empty()) tokens.push_back(current);
  1073. return tokens;
  1074. }
  1075. // v2.4.4 — offline sub-db placement audit / repair.
  1076. //
  1077. // `verify-subdbs` is read-only and safe against a running server.
  1078. // `reconcile-subdbs --apply` rewrites document placement and REQUIRES the
  1079. // service to be stopped: it holds an LMDB write txn over the whole env.
  1080. int cmdSubdbs(const std::string& command, const std::vector<std::string>& params) {
  1081. std::string env_path, project;
  1082. bool apply = false;
  1083. bool stamp = false;
  1084. for (size_t i = 0; i < params.size(); ++i) {
  1085. const std::string& p = params[i];
  1086. if (p == "--env" && i + 1 < params.size()) {
  1087. env_path = params[++i];
  1088. } else if (p == "--project" && i + 1 < params.size()) {
  1089. project = params[++i];
  1090. } else if (p == "--apply") {
  1091. apply = true;
  1092. } else if (p == "--stamp-identity") {
  1093. stamp = true;
  1094. } else {
  1095. printError("unknown argument: " + p);
  1096. return 2;
  1097. }
  1098. }
  1099. if (env_path.empty()) {
  1100. printError("--env <path> is required (e.g. "
  1101. "/var/lib/smartbotic-database/projects/default/env)");
  1102. return 2;
  1103. }
  1104. if (command == "verify-subdbs" && apply) {
  1105. printError("--apply is not valid for verify-subdbs; use reconcile-subdbs");
  1106. return 2;
  1107. }
  1108. try {
  1109. auto report = smartbotic::db::storage::audit(env_path, project);
  1110. smartbotic::db::storage::print_audit(report);
  1111. if (command == "verify-subdbs") {
  1112. return report.misplaced.empty() ? 0 : 1;
  1113. }
  1114. if (!apply) {
  1115. std::cout << "\n" << C_YELLOW << "Dry run." << C_RESET
  1116. << " Re-run with --apply to perform the repair.\n"
  1117. << C_DIM
  1118. << "Stop the service first, and take a backup: the repair "
  1119. "rewrites document placement in place.\n"
  1120. << C_RESET;
  1121. return report.misplaced.empty() ? 0 : 1;
  1122. }
  1123. if (stamp) {
  1124. std::cout << "\n" << C_YELLOW << "--stamp-identity:" << C_RESET
  1125. << " writing sentinels. This REQUIRES the server binary to be"
  1126. " v2.4.4 or newer;\n earlier builds do not skip the sentinel"
  1127. " and will fail on scan.\n";
  1128. }
  1129. std::cout << "\nApplying...\n";
  1130. auto res = smartbotic::db::storage::repair(env_path, report, stamp);
  1131. std::cout << C_GREEN << "done." << C_RESET
  1132. << " moved=" << res.moved
  1133. << " quarantined=" << res.quarantined
  1134. << " stamped=" << res.stamped << "\n";
  1135. for (const auto& e : res.errors) {
  1136. std::cerr << C_RED << " warn: " << C_RESET << e << "\n";
  1137. }
  1138. return res.errors.empty() ? 0 : 1;
  1139. } catch (const std::exception& e) {
  1140. printError(e.what());
  1141. return 1;
  1142. }
  1143. }
  1144. } // anonymous namespace
  1145. int main(int argc, char* argv[]) {
  1146. Args args;
  1147. // Parse flags
  1148. std::vector<std::string> positional;
  1149. for (int i = 1; i < argc; ++i) {
  1150. std::string arg = argv[i];
  1151. if (arg == "--address" && i + 1 < argc) {
  1152. args.address = argv[++i];
  1153. } else if (arg == "--project" && i + 1 < argc) {
  1154. args.project = argv[++i];
  1155. } else if (arg == "--help" || arg == "-h") {
  1156. printUsage();
  1157. return 0;
  1158. } else if (arg == "--version" || arg == "-V") {
  1159. // Offline, and before anything else: a version check must not need a
  1160. // reachable server. Same format as the service binary so the two can
  1161. // be compared at a glance.
  1162. std::cout << "smartbotic-db-cli version " << SMARTBOTIC_DB_VERSION_STRING
  1163. << " (commit " << SMARTBOTIC_DB_GIT_COMMIT_STRING << ")\n"
  1164. // The LOADED client library, which can differ from the
  1165. // binary's own version if only one package was upgraded.
  1166. // That divergence is exactly what an operator is trying to
  1167. // diagnose when they ask a binary for its version.
  1168. << "libsmartbotic-db-client "
  1169. << smartbotic::database::clientLibraryVersion()
  1170. << " (commit " << smartbotic::database::clientLibraryCommit() << ")\n";
  1171. return 0;
  1172. } else {
  1173. positional.push_back(arg);
  1174. }
  1175. }
  1176. if (!positional.empty()) {
  1177. args.command = positional[0];
  1178. args.params.assign(positional.begin() + 1, positional.end());
  1179. }
  1180. // Offline helpers — these don't need a running server, so dispatch
  1181. // them before opening the gRPC channel.
  1182. if (args.command == "generate-auth-key") {
  1183. return cmdGenerateAuthKey();
  1184. }
  1185. if (args.command == "generate-tls-cert") {
  1186. return cmdGenerateTlsCert(args.params);
  1187. }
  1188. if (args.command == "verify-subdbs" || args.command == "reconcile-subdbs") {
  1189. return cmdSubdbs(args.command, args.params);
  1190. }
  1191. // Connect
  1192. smartbotic::database::Client::Config clientCfg{.address = args.address};
  1193. if (!args.project.empty()) clientCfg.project = args.project;
  1194. smartbotic::database::Client client(clientCfg);
  1195. client.connect();
  1196. // Scriptable mode: single command
  1197. if (!args.command.empty()) {
  1198. return execCommand(client, args.command, args.params, args.project) ? 0 : 1;
  1199. }
  1200. // Interactive mode
  1201. std::cout << C_BOLD << "smartbotic-db-cli" << C_RESET
  1202. << " connected to " << C_CYAN << args.address << C_RESET << "\n"
  1203. << C_DIM << "Type 'help' for commands, 'exit' to quit." << C_RESET << "\n";
  1204. std::string line;
  1205. while (true) {
  1206. std::cout << C_YELLOW << "> " << C_RESET;
  1207. if (!std::getline(std::cin, line)) break;
  1208. // Trim
  1209. auto start = line.find_first_not_of(" \t");
  1210. if (start == std::string::npos) continue;
  1211. line = line.substr(start);
  1212. if (line == "exit" || line == "quit" || line == "q") break;
  1213. if (line.empty()) continue;
  1214. auto tokens = tokenize(line);
  1215. if (tokens.empty()) continue;
  1216. auto cmd = tokens[0];
  1217. std::vector<std::string> params(tokens.begin() + 1, tokens.end());
  1218. execCommand(client, cmd, params, args.project);
  1219. }
  1220. return 0;
  1221. }