Sfoglia il codice sorgente

v2.4 Stage A: per-listener config schema + back-compat shim

First step of v2.4 listeners/TLS/auth. Adds the data structure and
parsing without changing runtime behaviour — Stage B uses it.

## Schema

`Config::listeners[]` is a vector of `ListenerConfig` each carrying:
- bind (default 127.0.0.1) + port (default 9004)
- tls: {enabled, cert_path, key_path, auto_self_signed_if_missing}
- auth: {required, keys[]}

## Back-compat

Every existing v2.3 config file omits `storage.listeners`. The parser
synthesises one listener from the legacy `storage.bind_address` +
`storage.rpc_port` fields, with TLS off and auth off. Net effect on
upgrade: zero observable change for existing deployments.

## What does NOT change in this commit

`startGrpcServer` still uses the legacy bindAddress + rpcPort fields
directly. Stage B replaces that with a loop over the synthesised /
configured `listeners[]`. So Stage A is a pure schema addition.

Plan: docs/superpowers/plans/2026-05-31-v2.4-listeners-tls-auth.md
fszontagh 1 mese fa
parent
commit
b944f5e929

+ 184 - 0
docs/superpowers/plans/2026-05-31-v2.4-listeners-tls-auth.md

@@ -0,0 +1,184 @@
+# v2.4 listeners, TLS, and auth
+
+Origin: cross-droplet deployment scenario — DB server on one droplet,
+client on another. The existing v2.3 listener is plaintext + no auth,
+which is fine for local fleet but unsafe across the public internet.
+v2.4 adds per-listener configuration so admins can independently set
+bind address, TLS, and auth policy per network interface, while
+preserving the v2.3 default of "127.0.0.1 plaintext, no auth" for
+existing consumers.
+
+## Settled design (operator decisions, 2026-05-31)
+
+| Axis | Decision |
+|------|----------|
+| Listener model | **Per-bind listeners**: `storage.listeners[]` array. Each entry has its own bind, port, TLS, auth policy. One process, multiple `grpc::Server` instances, same `DatabaseGrpcImpl` registered on each. |
+| Auth on protected listeners | **API key in `authorization: Bearer <key>` metadata.** Constant-time compare against a list in config. Rotation = add new key, distribute to clients, remove old key. |
+| TLS cert when operator hasn't supplied one | **Auto-generate self-signed at first boot** when `tls.enabled: true` AND `tls.auto_self_signed_if_missing: true` (default). Stored at `<dataDir>/tls/auto_self_signed.{pem,key}`. WARN log: "self-signed, OK for dev, drop a real cert at cert_path for prod". |
+| Default policy | **127.0.0.1 listener is preserved with no TLS, no auth** so v2.3 consumers keep working without code change. Any additional listener (LAN, public) is opt-in via config and defaults TLS + auth ON when added. |
+| Version | **v2.4.0** — minor bump signaling new attack surface for operators to think about. |
+
+## Config schema (`storage.listeners`)
+
+```jsonc
+{
+  "storage": {
+    // v2.3 back-compat: if storage.listeners is absent AND old keys
+    // (bind_address, rpc_port) are set, synthesize ONE listener with
+    // those values + no TLS + no auth. Once an operator sets the new
+    // listeners array, the old keys are ignored.
+    "listeners": [
+      {
+        "bind": "127.0.0.1",
+        "port": 9004,
+        "tls": { "enabled": false },
+        "auth": { "required": false }
+      },
+      {
+        "bind": "0.0.0.0",        // or a specific LAN/public IP
+        "port": 9444,
+        "tls": {
+          "enabled": true,
+          "cert_path": "/etc/smartbotic-database/tls/server.pem",
+          "key_path":  "/etc/smartbotic-database/tls/server.key",
+          "auto_self_signed_if_missing": true
+        },
+        "auth": {
+          "required": true,
+          "keys": ["base64-encoded-key-1", "base64-encoded-key-2"]
+        }
+      }
+    ]
+  }
+}
+```
+
+## Listener semantics
+
+- For each listener config, build a `grpc::ServerBuilder` + `grpc::Server`.
+  Use `grpc::SslServerCredentials` when `tls.enabled`, else
+  `grpc::InsecureServerCredentials`.
+- The same `DatabaseGrpcImpl` + `DatabaseReplicationGrpcImpl` instances
+  are registered on every server (services are stateless from gRPC's
+  POV; all state lives in `DatabaseService`).
+- Each server runs on its own background thread; shutdown signals all
+  of them.
+- Per-listener interceptor enforces auth when configured. The
+  interceptor pulls `authorization: Bearer <key>` from request metadata,
+  validates against the listener's `auth.keys` list, returns
+  `UNAUTHENTICATED` on mismatch. Constant-time string compare.
+
+## Backwards compat shim
+
+`service/src/config/config_loader.cpp` parses the new schema. If
+`storage.listeners` is absent AND any of the old keys
+(`storage.bind_address`, `storage.rpc_port`) are present, synthesize:
+
+```json
+{
+  "listeners": [
+    {
+      "bind": "<old bind_address or 0.0.0.0>",
+      "port": <old rpc_port or 9004>,
+      "tls": { "enabled": false },
+      "auth": { "required": false }
+    }
+  ]
+}
+```
+
+This means: every existing config keeps working. Operators who want
+TLS+auth opt in by writing the new array.
+
+## Self-signed cert generation (Stage C detail)
+
+Triggered when:
+- `tls.enabled: true` AND
+- `tls.cert_path` doesn't exist OR is unreadable AND
+- `tls.auto_self_signed_if_missing: true` (default)
+
+Algorithm via OpenSSL C API:
+1. Generate 4096-bit RSA key.
+2. Build X.509 cert: CN=`<listener.bind>`, SubjectAltName includes
+   `DNS:localhost`, `IP:127.0.0.1`, and the configured `bind` address.
+   10-year validity (3650 days). SHA-256 self-sign.
+3. Write key to `<dataDir>/tls/auto_self_signed.key` (mode 0600).
+4. Write cert to `<dataDir>/tls/auto_self_signed.pem` (mode 0644).
+5. Log:
+   ```
+   WARN: TLS listener <bind>:<port>: no operator cert at <cert_path>,
+   generated self-signed cert at <dataDir>/tls/auto_self_signed.*.
+   OK for dev. For production, drop a real cert + key at cert_path.
+   ```
+6. Use the generated cert for the listener.
+
+If an operator later drops a real cert at `cert_path`, restart picks
+it up automatically (auto-generated cert is checked second).
+
+## Client-side (Stage E)
+
+```cpp
+struct Client::Config {
+    // ... existing fields ...
+
+    // v2.4 — TLS + auth.
+    bool tls_enabled = false;
+    std::string tls_ca_cert_path;          // empty + tls_enabled = use system trust
+    bool tls_insecure_skip_verify = false; // dev only; accepts self-signed
+    std::string auth_token;                // matches one entry in server.auth.keys
+};
+```
+
+- When `tls_enabled`, build `grpc::SslCredentials`. Load CA cert from
+  `tls_ca_cert_path` if non-empty; else use system trust roots.
+  `tls_insecure_skip_verify` uses a no-op verify callback — for talking
+  to a server with a self-signed cert without distributing the cert
+  separately. **Loud client log on every connect when this is set.**
+- When `auth_token` is non-empty, install a per-call client interceptor
+  that adds `authorization: Bearer <auth_token>` to every outgoing
+  metadata.
+
+## CLI tools (Stage F)
+
+`smartbotic-db-cli generate-auth-key`
+  - Emit a 32-byte cryptographically random key as base64 to stdout.
+  - Operators paste into server config + their client config.
+
+`smartbotic-db-cli generate-tls-cert --bind <addr> [--out-cert PATH] [--out-key PATH] [--days N]`
+  - Generate a self-signed cert for the given bind address.
+  - Defaults: out-cert=./server.pem, out-key=./server.key, days=3650.
+  - Useful so operators can pre-create cert + key without booting the
+    server in auto-generate mode.
+
+## Stage sequencing
+
+| Stage | Owner | Effort |
+|-------|-------|--------|
+| A — config schema + back-compat shim | main | small |
+| B — multi-listener gRPC startup | main | medium |
+| C — TLS listener + self-signed gen | main | medium |
+| D — auth interceptor | main | small |
+| E — client TLS + auth | could be subagent | medium |
+| F — CLI tools | could be subagent | small |
+| G — tests + CLAUDE.md + release | main | medium |
+
+Stages E + F are independent of each other and of C/D once A+B are in.
+Worth dispatching one or both as subagents to compress the wall-clock.
+
+## What stays in scope; what's deferred
+
+- v2.4.0: listener config, TLS, API key auth, self-signed bootstrap,
+  CLI helpers, tests, docs, release.
+- Deferred: mTLS (revisit if API-key proves insufficient for any
+  consumer), per-listener access-control-lists, token expiry +
+  refresh, OIDC integration, audit logging of auth attempts. None
+  blocking the cross-droplet use case.
+
+## Open questions retired during implementation
+
+- Should `auth.keys` accept hashed keys to avoid plaintext in config?
+  Plaintext is simpler; the config file is mode 0600 on disk; rotation
+  is fast. Defer hashed keys to v2.4.x if any operator asks.
+- Should auth-failed requests log the failure with the offending peer?
+  Yes, at WARN level, with the peer IP — useful for spotting probes.
+  Rate-limit the log line per-peer at 1/min to avoid log spam.

+ 46 - 0
service/src/database_service.cpp

@@ -534,6 +534,38 @@ DatabaseService::Config DatabaseService::parseConfig(const nlohmann::json& json)
         config.bindAddress = db.value("bind_address", config.bindAddress);
         config.rpcPort = db.value("rpc_port", config.rpcPort);
 
+        // v2.4 — parse the per-listener fleet. If "listeners" is set,
+        // it overrides the legacy bind_address+rpc_port. Otherwise we
+        // synthesise one listener below using those legacy fields.
+        if (db.contains("listeners") && db["listeners"].is_array()) {
+            for (const auto& l : db["listeners"]) {
+                ListenerConfig lc;
+                lc.bind = l.value("bind", lc.bind);
+                lc.port = l.value("port", lc.port);
+
+                if (l.contains("tls") && l["tls"].is_object()) {
+                    const auto& t = l["tls"];
+                    lc.tls.enabled = t.value("enabled", false);
+                    lc.tls.cert_path = t.value("cert_path", std::string{});
+                    lc.tls.key_path = t.value("key_path", std::string{});
+                    lc.tls.auto_self_signed_if_missing =
+                        t.value("auto_self_signed_if_missing", true);
+                }
+
+                if (l.contains("auth") && l["auth"].is_object()) {
+                    const auto& a = l["auth"];
+                    lc.auth.required = a.value("required", false);
+                    if (a.contains("keys") && a["keys"].is_array()) {
+                        for (const auto& k : a["keys"]) {
+                            if (k.is_string()) lc.auth.keys.push_back(k.get<std::string>());
+                        }
+                    }
+                }
+
+                config.listeners.push_back(std::move(lc));
+            }
+        }
+
         // Expand environment variables in data directory
         std::string dataDir = db.value("data_directory", "");
         if (dataDir.find("${HOME}") != std::string::npos) {
@@ -697,6 +729,20 @@ DatabaseService::Config DatabaseService::parseConfig(const nlohmann::json& json)
         }
     }
 
+    // v2.4 back-compat shim. If no listeners[] was provided, synthesize
+    // one from the legacy bind_address + rpc_port fields. This is what
+    // every existing v2.3 deployment will hit on first v2.4 boot —
+    // their configs don't know about listeners[] yet, and they get
+    // exactly the same listener they had before.
+    if (config.listeners.empty()) {
+        ListenerConfig legacy;
+        legacy.bind = config.bindAddress;
+        legacy.port = config.rpcPort;
+        legacy.tls.enabled = false;
+        legacy.auth.required = false;
+        config.listeners.push_back(std::move(legacy));
+    }
+
     return config;
 }
 

+ 42 - 0
service/src/database_service.hpp

@@ -46,8 +46,50 @@ namespace smartbotic::database {
  */
 class DatabaseService {
 public:
+    // v2.4 — per-listener configuration. Each binding can have its own
+    // TLS material and auth policy. The default 127.0.0.1 listener
+    // preserves v2.3 behaviour (plaintext, no auth) so existing local
+    // consumers keep working without code change.
+    struct ListenerConfig {
+        std::string bind = "127.0.0.1";
+        uint16_t port = 9004;
+
+        struct TlsConfig {
+            bool enabled = false;
+            std::filesystem::path cert_path;
+            std::filesystem::path key_path;
+            // When TLS is enabled but cert+key are missing/unreadable, the
+            // server auto-generates a 10-year self-signed cert to
+            // <dataDir>/tls/auto_self_signed.{pem,key}. Operators who run
+            // a real PKI override by dropping their cert at cert_path.
+            bool auto_self_signed_if_missing = true;
+        } tls;
+
+        struct AuthConfig {
+            bool required = false;
+            // Bearer tokens accepted by this listener. Constant-time
+            // compared against the value of the `authorization` gRPC
+            // metadata header. Rotation = add new key, distribute,
+            // remove old key. Empty list with required=true is a config
+            // error caught at startup.
+            std::vector<std::string> keys;
+        } auth;
+    };
+
     struct Config {
         std::string nodeId = "storage-1";
+
+        // v2.4 — listener fleet. Empty means "synthesize a single
+        // back-compat listener from the legacy bindAddress + rpcPort
+        // fields below". Once any listener is configured here, the
+        // legacy fields are ignored.
+        std::vector<ListenerConfig> listeners;
+
+        // v2.3 legacy single-listener fields. Kept so existing
+        // config.json files continue to work — the config loader
+        // synthesises a ListenerConfig from these when `listeners` is
+        // empty. Operators who want TLS or auth must use the
+        // `listeners[]` schema instead of these.
         std::string bindAddress = "0.0.0.0";
         uint16_t rpcPort = 9004;