|
@@ -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.
|