Prechádzať zdrojové kódy

v2.4 Stage E: Client TLS + auth_token

Adds four fields to Client::Config — tls_enabled, tls_ca_cert_path,
tls_insecure_skip_verify, auth_token — and the connect()/setDeadline()
wiring to make them carry through every outbound RPC.

TLS channel construction is centralised in buildChannelCredentials():
plaintext (v2.3 back-compat default), grpc::SslCredentials with
operator-supplied CA cert or system trust roots, or experimental
TlsChannelCredentialsOptions + NoOpCertificateVerifier when
tls_insecure_skip_verify is set (dev only, loud WARN on connect, fails
loud if the experimental API returns null rather than silently
downgrading).

Auth metadata goes through ClientContext::AddMetadata("authorization",
"Bearer <token>"), attached by a single attachAuth() helper that
setDeadline() also calls. The streaming RPCs (Subscribe, UploadFile,
DownloadFile) and the 10-minute-deadline MigrateCollectionTimestamps
call attachAuth() directly since they set their own deadlines. The
per-context route was chosen over a gRPC interceptor factory because
every call site already funnels through setDeadline() — covering them
all is one helper call rather than a gRPC experimental interface.

SOVERSION unchanged: this is additive. Existing v2.3 callers that
construct Client::Config with no TLS/auth fields land on plaintext +
no-auth and continue to talk to the 127.0.0.1 listener exactly as
before.

Build green, ctest 14/14 green.
fszontagh 1 mesiac pred
rodič
commit
96d21d63a3

+ 62 - 0
client/include/smartbotic/database/client.hpp

@@ -45,6 +45,68 @@ public:
         // and then calls `client.find("conversations", ...)` which the
         // client sends as "shadowman:conversations" to the server.
         std::string project = "default";
+
+        // ===== v2.4 — TLS + bearer-token auth =====
+
+        // When true, the client builds a TLS-secured channel instead of the
+        // default plaintext one. Pair with the `tls.enabled: true` listener
+        // entry on the server. Default false preserves the v2.3 behavior of
+        // talking to the 127.0.0.1 plaintext listener without any code
+        // changes on existing consumers.
+        bool tls_enabled = false;
+
+        // Path to a PEM-encoded CA certificate that is allowed to sign the
+        // server's cert. Required when the server presents a self-signed
+        // cert (e.g. the one produced by `tls.auto_self_signed_if_missing`)
+        // unless `tls_insecure_skip_verify` is set.
+        //
+        // When empty AND `tls_insecure_skip_verify` is false, gRPC falls
+        // back to the system trust roots (typically /etc/ssl/certs). Use
+        // that for production servers fronted by a real CA-issued cert.
+        std::string tls_ca_cert_path;
+
+        // **DEV ONLY.** When true, the TLS handshake completes without
+        // verifying the server certificate against any trust root. Useful
+        // for talking to a server with a self-signed cert without
+        // distributing the cert to every client.
+        //
+        // The client logs a loud WARN on every `connect()` when this is
+        // set. Never enable this for production traffic — it disables the
+        // MITM protection TLS exists to provide. Prefer pointing
+        // `tls_ca_cert_path` at the server's self-signed cert when at all
+        // practical.
+        //
+        // Implementation note: routed through
+        // `grpc::experimental::TlsChannelCredentialsOptions` with a
+        // `NoOpCertificateVerifier` and `set_verify_server_certs(false)`.
+        // Because the gRPC TLS-credentials API is still in `experimental`,
+        // the exact symbol names may shift between minor gRPC releases;
+        // if a future toolchain ships without `NoOpCertificateVerifier`
+        // the client falls back to `tls_ca_cert_path`-only mode and
+        // refuses to connect (loud ERROR) — that fallback is documented
+        // in the source so an operator can recognise the symptom.
+        bool tls_insecure_skip_verify = false;
+
+        // Bearer token sent in the `authorization: Bearer <token>` gRPC
+        // metadata on every outgoing call. Must match one entry in the
+        // target listener's `auth.keys` list on the server side. Empty
+        // means no auth metadata is attached (only safe against the v2.3
+        // back-compat 127.0.0.1 plaintext listener — protected listeners
+        // will return UNAUTHENTICATED).
+        //
+        // Implementation: attached via `grpc::ClientContext::AddMetadata`
+        // in the same `setDeadline()` helper that every RPC already goes
+        // through. We chose the per-context route over a gRPC interceptor
+        // factory because every call site in this library already routes
+        // through `setDeadline()`, so a single one-liner there covers all
+        // RPC types (unary, server-streaming via `Subscribe`, and bidi
+        // file upload/download) without the experimental-API churn of
+        // `grpc::experimental::ClientInterceptorFactoryInterface`.
+        //
+        // Rotation: add a new key on the server, switch the client over,
+        // remove the old key on the server. No restart required on the
+        // client.
+        std::string auth_token;
     };
 
     explicit Client(Config config);

+ 119 - 2
client/src/client.cpp

@@ -3,12 +3,17 @@
 #include <database.grpc.pb.h>
 
 #include <grpcpp/grpcpp.h>
+#include <grpcpp/security/credentials.h>
+#include <grpcpp/security/tls_certificate_verifier.h>
+#include <grpcpp/security/tls_credentials_options.h>
 #include <spdlog/spdlog.h>
 
 #include <atomic>
 #include <chrono>
 #include <cstdlib>
+#include <fstream>
 #include <mutex>
+#include <sstream>
 #include <thread>
 
 namespace smartbotic::database {
@@ -38,6 +43,16 @@ uint32_t computeBackoffMs(uint32_t baseMs, uint32_t attempt, uint32_t capMs, dou
     if (jitter < 0.1) jitter = 0.1;
     return static_cast<uint32_t>(exp * jitter);
 }
+
+// v2.4 — slurp a PEM-encoded CA cert file into a string. Returns empty
+// string on read failure; caller logs the surrounding context.
+std::string readCaCertFile(const std::string& path) {
+    std::ifstream in(path, std::ios::binary);
+    if (!in.is_open()) return {};
+    std::ostringstream buf;
+    buf << in.rdbuf();
+    return buf.str();
+}
 } // anonymous namespace
 
 // ===== PIMPL Implementation =====
@@ -80,16 +95,25 @@ public:
             channelArgs.SetMaxReceiveMessageSize(100 * 1024 * 1024);
             channelArgs.SetMaxSendMessageSize(100 * 1024 * 1024);
 
+            std::shared_ptr<grpc::ChannelCredentials> creds = buildChannelCredentials();
+            if (!creds) {
+                spdlog::error("Database client connection failed: could not build channel credentials");
+                return false;
+            }
+
             channel_ = grpc::CreateCustomChannel(
                 config_.address,
-                grpc::InsecureChannelCredentials(),
+                creds,
                 channelArgs
             );
 
             stub_ = smartbotic::databasepb::DatabaseService::NewStub(channel_);
             connected_ = true;
 
-            spdlog::info("Database client connected to {}", config_.address);
+            const char* scheme = config_.tls_enabled ? "tls" : "plaintext";
+            const char* authed = config_.auth_token.empty() ? "no-auth" : "bearer-auth";
+            spdlog::info("Database client connected to {} ({}, {})",
+                         config_.address, scheme, authed);
             return true;
         } catch (const std::exception& e) {
             spdlog::error("Database client connection failed: {}", e.what());
@@ -97,6 +121,70 @@ public:
         }
     }
 
+    // v2.4 — build the ChannelCredentials matching Config's TLS settings.
+    //
+    // Three cases:
+    //   1. !tls_enabled                              → InsecureChannelCredentials
+    //      (v2.3 back-compat default).
+    //   2. tls_enabled && !insecure_skip_verify      → SslCredentials with
+    //      pem_root_certs read from tls_ca_cert_path, or empty (= system
+    //      trust roots) when the path is unset.
+    //   3. tls_enabled && insecure_skip_verify       → experimental
+    //      TlsChannelCredentialsOptions with NoOpCertificateVerifier and
+    //      set_verify_server_certs(false). Loud WARN on every connect.
+    std::shared_ptr<grpc::ChannelCredentials> buildChannelCredentials() {
+        if (!config_.tls_enabled) {
+            return grpc::InsecureChannelCredentials();
+        }
+
+        if (config_.tls_insecure_skip_verify) {
+            spdlog::warn(
+                "Database client: connecting with tls_insecure_skip_verify — "
+                "DEV ONLY. Server certificate will NOT be validated against any "
+                "trust root. Drop the server's self-signed cert at "
+                "tls_ca_cert_path for any production-shaped use.");
+
+            // Route through gRPC's experimental TLS API. This is the path
+            // gRPC documents for "accept any cert" — combine
+            // NoOpCertificateVerifier with set_verify_server_certs(false).
+            grpc::experimental::TlsChannelCredentialsOptions tlsOpts;
+            tlsOpts.set_certificate_verifier(
+                std::make_shared<grpc::experimental::NoOpCertificateVerifier>());
+            tlsOpts.set_verify_server_certs(false);
+            tlsOpts.set_check_call_host(false);
+            auto creds = grpc::experimental::TlsCredentials(tlsOpts);
+            if (!creds) {
+                // No fallback that "trusts everything" without
+                // experimental TLS — if the build/runtime can't produce
+                // experimental TlsCredentials, refuse to silently degrade
+                // to system-trust mode (which would reject the
+                // self-signed cert anyway). The operator should bake the
+                // server's self-signed cert into tls_ca_cert_path.
+                spdlog::error(
+                    "Database client: tls_insecure_skip_verify requested but "
+                    "experimental TlsCredentials returned null. Set "
+                    "tls_ca_cert_path to the server's self-signed PEM "
+                    "instead.");
+                return nullptr;
+            }
+            return creds;
+        }
+
+        grpc::SslCredentialsOptions sslOpts;
+        if (!config_.tls_ca_cert_path.empty()) {
+            sslOpts.pem_root_certs = readCaCertFile(config_.tls_ca_cert_path);
+            if (sslOpts.pem_root_certs.empty()) {
+                spdlog::error(
+                    "Database client: could not read tls_ca_cert_path '{}'",
+                    config_.tls_ca_cert_path);
+                return nullptr;
+            }
+        }
+        // Else leave pem_root_certs empty → gRPC uses system trust roots
+        // (or GRPC_DEFAULT_SSL_ROOTS_FILE_PATH env var, per gRPC docs).
+        return grpc::SslCredentials(sslOpts);
+    }
+
     void disconnect() {
         connected_ = false;
         stub_.reset();
@@ -882,6 +970,7 @@ public:
         // Longer deadline — can iterate many rows
         auto deadline = std::chrono::system_clock::now() + std::chrono::minutes(10);
         context.set_deadline(deadline);
+        attachAuth(context);
 
         Client::TimestampMigrationResult out;
         auto status = stub_->MigrateCollectionTimestamps(&context, request, &response);
@@ -1064,6 +1153,9 @@ public:
     std::shared_ptr<void> subscribe(const std::vector<std::string>& collections,
                                     Client::EventCallback callback) {
         auto context = std::make_shared<grpc::ClientContext>();
+        // Subscribe is a long-running stream — no deadline — but the auth
+        // metadata still has to land on the initial request headers.
+        attachAuth(*context);
 
         smartbotic::databasepb::SubscribeRequest request;
         for (const auto& coll : collections) {
@@ -1403,6 +1495,7 @@ public:
         auto timeout_ms = config_.timeoutMs + (data.size() / (1024 * 1024)) * 1000;
         context.set_deadline(
             std::chrono::system_clock::now() + std::chrono::milliseconds(timeout_ms));
+        attachAuth(context);
 
         smartbotic::databasepb::UploadFileResponse response;
         auto writer = stub_->UploadFile(&context, &response);
@@ -1446,6 +1539,7 @@ public:
         grpc::ClientContext context;
         context.set_deadline(
             std::chrono::system_clock::now() + std::chrono::milliseconds(config_.timeoutMs * 10));
+        attachAuth(context);
 
         auto reader = stub_->DownloadFile(&context, request);
 
@@ -1560,10 +1654,33 @@ public:
     }
 
 private:
+    // Per-context setup: deadline + (v2.4) bearer-token auth metadata.
+    //
+    // Most RPC call sites in this library funnel through this helper —
+    // unary (insert, get, update, ...) plus the unary file ops that need
+    // the default deadline. Long-running streams (Subscribe) and the
+    // file-streaming RPCs (UploadFile/DownloadFile) compute their own
+    // deadlines and call `attachAuth()` separately so the auth metadata
+    // still lands on every outbound RPC. Centralising both concerns here
+    // means we don't need a gRPC ClientInterceptorFactoryInterface — the
+    // interceptor route is also offered by gRPC++ but it sits in
+    // grpc::experimental:: and would need to be re-validated on every
+    // grpc minor; the per-context style keeps Stage E independent of
+    // gRPC's TLS/interceptor experimental flux.
     void setDeadline(grpc::ClientContext& context) {
         context.set_deadline(
             std::chrono::system_clock::now() + std::chrono::milliseconds(config_.timeoutMs)
         );
+        attachAuth(context);
+    }
+
+    // v2.4 — attach the bearer-token authorization metadata if configured.
+    // Use this on contexts that set their own deadline (streams,
+    // long-running ops) so the auth metadata still lands.
+    void attachAuth(grpc::ClientContext& context) {
+        if (!config_.auth_token.empty()) {
+            context.AddMetadata("authorization", "Bearer " + config_.auth_token);
+        }
     }
 
     Config config_;