29fe688b7a
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
20 KiB
Markdown
232 lines
20 KiB
Markdown
# Report 0006 — unibus: descentralización / alta disponibilidad (issue 0003, fases 0003a–0003e)
|
||
|
||
- **Fecha:** 07/06/2026
|
||
- **Autor:** agente (Claude Opus 4.8)
|
||
- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`). Paquetes `embeddednats`, `busauth`, `membership`, `blobstore`, `client`; `cmd/membershipd`; `dev/feature_flags.json`.
|
||
- **Estado:** done — entrega 0003a–0003e en `master` del sub-repo. El despliegue multi-nodo real (0003f) lo ejecuta el humano; al final hay un runbook.
|
||
- **Origen:** issue `dev/issues/0003-decentralization-ha.md`. Cierra además el residual H4 (ACL por subject) que el hardening (report 0005) difirió a este issue, y el agujero de anti-replay multi-nodo que la auditoría (report 0004) señaló como bloqueante antes del cluster.
|
||
|
||
## Resumen
|
||
|
||
unibus deja de ser un SPOF: el servidor NATS embebido puede formar cluster (data plane replicado), el control plane y los blobs pueden vivir en JetStream KV / Object Store replicados, el cliente hace failover entre nodos, y el anti-replay pasa a un store de nonces compartido. Todo está construido por **branch-by-abstraction** detrás del flag `decentralized` (off): el comportamiento de un solo nodo (SQLite + disco) no cambia y `master` sigue desplegable en cada paso.
|
||
|
||
El issue marca el rollout R1→R3: con 2 nodos (magnus + homer) se despliega en R1 (réplicas=1, sin tolerancia a fallo todavía); cuando entre el tercer nodo se escala a R3 (`nats stream/kv update --replicas 3`) para HA real (quorum 2/3). El código soporta réplicas configurables; el despliegue elige el número.
|
||
|
||
Historia (vista `git log --first-parent master`):
|
||
|
||
```
|
||
da42051 Merge issue/0003e-client-failover: client failover + replicated nonce store + subject ACL (H4)
|
||
649dc9e Merge issue/0003d-objectstore: replicated blobs on NATS Object Store
|
||
94e7ced Merge issue/0003c-migrate-kv: idempotent SQLite->KV migration + backup
|
||
b8c9b2b Merge issue/0003b-jetstream-store: Store interface + JetStream KV backend (fail-closed)
|
||
3230b31 Merge issue/0003a-cluster: NATS cluster routes (auth + mutual TLS)
|
||
```
|
||
|
||
Verificación global (sin caché, en `master` tras los 5 merges):
|
||
|
||
```
|
||
$ CGO_ENABLED=0 go build ./... # OK
|
||
$ CGO_ENABLED=0 go vet ./... # limpio
|
||
$ CGO_ENABLED=0 go test -count=1 ./...
|
||
ok cmd/membershipd 0.004s
|
||
ok pkg/blobstore 0.096s
|
||
ok pkg/busauth 0.007s
|
||
ok pkg/client 5.857s
|
||
ok pkg/embeddednats 6.559s
|
||
ok pkg/frame 0.002s
|
||
ok pkg/membership 3.059s
|
||
```
|
||
|
||
---
|
||
|
||
## Fase 0003a — Cluster NATS (routes con auth + TLS mutuo) ✅
|
||
|
||
**Qué.** `pkg/embeddednats.ServerConfig` gana `ServerName` (único por nodo, lo exige el RAFT de JetStream) y un `*ClusterConfig` opcional: nombre de cluster, host/puerto del listener de routes, URLs de los otros nodos, secreto de routes (`Username`/`Password` → `Cluster.Authorization`) y un `*tls.Config` de TLS mutuo. `Cluster` nil mantiene el servidor standalone (comportamiento legacy).
|
||
|
||
**Seguridad de routes (clave, del report de auditoría 0004).** Las routes son una frontera de confianza **server-to-server**, distinta del plano de clientes: autentican NODOS, no usuarios del bus. NO se reutiliza el authenticator nkey de clientes. `busauth.RouteTLSConfig(cert, key, ca)` construye el TLS mutuo: el nodo presenta su certificado firmado por la CA **y** verifica el del nodo entrante contra la misma CA (`RequireAndVerifyClientCert`), reusando la CA del issue 0001. Un nodo sin el secreto de cluster y sin un certificado firmado por la CA no puede unirse ni inyectar mensajes.
|
||
|
||
`cmd/membershipd` gana las flags de cluster (`--cluster-name/--server-name/--cluster-port/--routes/--cluster-user/--cluster-pass/--route-tls-cert/-key/-ca`). `validateClusterConfig` rechaza un cluster en bind público sin secreto de routes y sin TLS mutuo completo, y rechaza flags de route-TLS parciales (all-or-nothing).
|
||
|
||
**Evidencia (DoD: golden + 2 edge + 2 error path):**
|
||
|
||
```
|
||
$ CGO_ENABLED=0 go test ./pkg/embeddednats/ -run TestCluster -v
|
||
--- PASS: TestClusterForwardsAcrossNodes (2 nodos: subject publicado en n1 llega a un suscriptor en n0)
|
||
--- PASS: TestClusterThreeNodesForward (3 nodos, forma HA: publish en n2 llega a n0)
|
||
--- PASS: TestClusterMutualTLSForwards (forwarding sobre routes con TLS mutuo)
|
||
--- PASS: TestClusterRejectsBadRouteAuth (password de cluster incorrecto -> 0 routes; el cluster legítimo no cambia)
|
||
--- PASS: TestClusterRejectsUnsignedNode (cert no firmado por la CA -> 0 routes)
|
||
$ CGO_ENABLED=0 go test ./cmd/membershipd/ -run 'TestClusterConfigPolicy|TestSplitRoutes' # PASS
|
||
```
|
||
|
||
**Gaps.** Los tests son in-process (varios `nats-server` embebidos en un proceso). El forwarding entre `membershipd`s reales en 3 VPS + escalado a R3 es 0003f (humano). `NumRoutes()` cuenta conexiones del pool de routes, no peers; los tests de rechazo comparan contra un baseline estabilizado en vez de un número fijo.
|
||
|
||
---
|
||
|
||
## Fase 0003b — Interfaz Store + jetstreamStore (KV) ✅
|
||
|
||
**Qué.** `membership.Store` pasa a interfaz (branch-by-abstraction). La implementación SQLite se renombra `sqliteStore` y sigue siendo el default (`Open(path)` la devuelve). `ErrNotFound` es un sentinel agnóstico del backend (la SQLite mapea `sql.ErrNoRows` a él; el control plane ya no importa `database/sql`). `jetstreamStore` (nuevo) implementa `Store` sobre cinco buckets KV replicados — `rooms`, `members`, `rooms_by_member` (índice inverso para `ListRoomsForEndpoint`), `room_keys`, `users` — con réplicas configurables (R1..R5). **Fail-closed**: cada lectura está acotada por `OpTimeout` e `IsAuthorized`/`HasAdmin` devuelven `false` ante cualquier error de backend (una pérdida de quorum del KV deniega, nunca admite), exactamente lo que la auditoría exigía.
|
||
|
||
Flag `decentralized` (off) añadido a `dev/feature_flags.json`.
|
||
|
||
**Evidencia (DoD: golden + edges + error path):**
|
||
|
||
```
|
||
$ CGO_ENABLED=0 go test ./pkg/membership/ -run TestJetStreamStore -v
|
||
--- PASS: TestJetStreamStoreRoomsCRUD (room cifrada + owner + invitado; latest-epoch + rekey)
|
||
--- PASS: TestJetStreamStoreUsers (add/get/authorize/list/revoke + gate admin, normalización case-insensitive, duplicado rechazado)
|
||
--- PASS: TestJetStreamStoreNotFound (mapeo ErrNotFound en misses)
|
||
--- PASS: TestJetStreamStoreIsAuthorizedFailClosed (NATS apagado -> IsAuthorized y HasAdmin DENIEGAN dentro del timeout)
|
||
```
|
||
|
||
**Gaps.** KV no tiene transacción multi-key: `CreateRoom`/`AddMember` son secuencias de Puts idempotentes con orden recoverable; documentado en el código. El **wiring de arranque** de `membershipd`-en-KV (selección del store según flag) se difiere a 0003e/0003f: tiene un ciclo bootstrap (la conexión interna de `membershipd` al NATS embebido debería autenticarse contra el mismo store que aún no existe), que pertenece al rediseño de sesión/deploy. El `jetstreamStore` queda completo, testeado y consumido inmediatamente por `migrate-to-kv` (0003c).
|
||
|
||
---
|
||
|
||
## Fase 0003c — migrate-to-kv (idempotente, con backup) ✅
|
||
|
||
**Qué.** `Snapshot`/`SealedKeyRecord` (volcado agnóstico del control plane: rooms con su epoch real, members, todas las filas de sealed-key por epoch, users con status). `ExportSnapshot` en ambos backends; `importSnapshot` del KV escribe con Puts crudos (conserva epoch/status, no resetea a defaults), así que la migración es fiel e idempotente (cada escritura es un overwrite → re-ejecutar converge). `MigrateSQLiteToKV` orquesta export→import; `BackupSQLite` hace una copia consistente con `VACUUM INTO` antes de migrar. Comando `membershipd migrate-to-kv --db <path> --nats-url <url> [--replicas N] [--ca <cert>] [--no-backup]`.
|
||
|
||
**Evidencia (DoD: paridad + idempotencia + backup) + smoke del binario:**
|
||
|
||
```
|
||
$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestMigrate|TestBackup' -v
|
||
--- PASS: TestMigrateSQLiteToKVParity (2 rooms una rekeyed a epoch 2 + user revocado; KV == SQLite tras migrar)
|
||
--- PASS: TestMigrateSQLiteToKVIdempotent (migrar dos veces -> mismo estado KV)
|
||
--- PASS: TestBackupSQLiteCreatesConsistentCopy (el backup reabre con los mismos datos)
|
||
|
||
$ # smoke del binario (seed user -> server -> migrate -> re-run)
|
||
$ membershipd user add --handle alice --sign-pub <64hex> --role admin --db unibus.db
|
||
$ membershipd --bind 127.0.0.1 --http-port 18888 --nats-port 14888 --db unibus.db ... &
|
||
$ membershipd migrate-to-kv --db unibus.db --nats-url nats://127.0.0.1:14888 --replicas 1
|
||
backed up unibus.db -> unibus.db.bak.<ts>
|
||
migrated to KV (replicas=1): 0 rooms, 0 members, 0 keys, 1 users
|
||
$ membershipd migrate-to-kv ... --no-backup # re-run idempotente: 1 users, igual
|
||
```
|
||
|
||
**Gaps.** El comando conecta al NATS con `--nats-url` (+ `--ca` opcional para TLS); la autenticación de la conexión interna con nkey de nodo en un cluster `enforce` se afina en el deploy 0003f.
|
||
|
||
---
|
||
|
||
## Fase 0003d — Blobs replicados (NATS Object Store) ✅
|
||
|
||
**Qué.** `blobstore.Store` pasa a interfaz. El backend de disco se renombra `diskStore` (default). `objectStore` (nuevo) implementa `Store` sobre un bucket NATS Object Store con réplicas configurables (R1..R5). El direccionamiento por contenido (sha256-hex) es idéntico, así que el contrato de wire no cambia y un `BlobRef` es portable entre backends. `membership.Server.blobs`/`NewServer` toman la interfaz.
|
||
|
||
**Evidencia (DoD: golden + edge + contrato):**
|
||
|
||
```
|
||
$ CGO_ENABLED=0 go test ./pkg/blobstore/ -v
|
||
--- PASS: TestObjectStoreRoundTrip (put/get/has + dedup content-addressed)
|
||
--- PASS: TestObjectStoreMissing (hash desconocido ausente e ilegible)
|
||
--- PASS: TestObjectStoreAddressMatchesDisk (Object Store y disco direccionan los mismos bytes al MISMO hash)
|
||
```
|
||
|
||
**Gaps.** Como el KV store, la selección del Object Store en `membershipd` se difiere al boot path decentralized; disco sigue default. Sin cuota/GC (eso es el issue 0002 / H9).
|
||
|
||
---
|
||
|
||
## Fase 0003e — Cliente failover + nonce replicado + ACL por subject (H4) ✅
|
||
|
||
Tres componentes, tres commits atómicos en la rama.
|
||
|
||
### 1. Failover de cliente
|
||
|
||
`Options.NatsServers` (seeds extra) y `Options.CtrlURLs` (control planes extra). El cliente conecta a la lista unida con `MaxReconnects(-1)` + `RetryOnFailedConnect`, así nats.go hace failover a un nodo vivo cuando cae el que tenía. `doJSON`/`putBlob`/`getBlob` prueban cada control plane en orden (failover de transporte; cada intento firma con nonce fresco, nunca es replay). `New/Connect/NewWithOptions` mantienen su firma (worker/chat/mobile/playground intactos). `ConnectedServer()`/`IsConnected()` para observabilidad.
|
||
|
||
```
|
||
$ CGO_ENABLED=0 go test ./pkg/client/ -run TestClientFailoverAcrossNodes -v
|
||
--- PASS (A suscrito en n0 recibe; se MATA el nodo de A; A reconecta al superviviente y SIGUE recibiendo — sesión intacta)
|
||
```
|
||
|
||
### 2. Anti-replay replicado (nonce store en KV)
|
||
|
||
El `nonceCache` por-proceso rompe el anti-replay en multi-nodo: un request capturado y reenviado a OTRO nodo (cuyo cache nunca vio el nonce) se aceptaría. `nonceStore` pasa a interfaz; `memNonceCache` (default) + `kvNonceStore` nuevo que reclama cada nonce con un `Create` atómico en un bucket compartido (primer uso gana; cualquier uso posterior en cualquier nodo rechaza). Error de backend → **fail closed** (rechaza). TTL del bucket = `nonceTTL` (2·clockSkew). `Server.UseReplicatedNonces(js, replicas)` lo activa por nodo.
|
||
|
||
```
|
||
$ CGO_ENABLED=0 go test ./pkg/membership/ -run TestReplicatedNonceRejectsCrossNodeReplay -v
|
||
--- PASS (request aceptada (200) en nodo A; mismo ts+nonce reenviado a nodo B -> 401 replayed; replay a A de nuevo -> 401)
|
||
```
|
||
|
||
### 3. ACL por subject derivada de pertenencia (residual H4)
|
||
|
||
`busauth.NewNkeyAuthenticatorACL` + `PermissionsFunc`: el authenticator, tras autorizar, deriva y `RegisterUser()`a permisos por subject; error de derivación → deniega (fail closed). `membership.SubjectACLFor(store)` mapea una identidad a los subjects de sus rooms + la infraestructura de cliente (`_INBOX.>`, `$JS.API.>`). `client.RefreshSession()` reconecta el data plane para que el authenticator re-derive permisos tras un cambio de membresía (NATS congela permisos al conectar).
|
||
|
||
```
|
||
$ CGO_ENABLED=0 go test ./pkg/membership/ -run 'TestSubjectACLIsolation|TestRefreshSessionGainsNewRoom' -v
|
||
--- PASS: TestSubjectACLIsolation (alice miembro de room.A: sub/pub room.A OK; sub y pub room.B -> permissions violation;
|
||
alice nunca lee el tráfico de bob en room.B; bob nunca recibe el publish cruzado de alice)
|
||
--- PASS: TestRefreshSessionGainsNewRoom (alice sin permiso para room B hasta que se la añade y llama RefreshSession; el reconnect
|
||
le concede el subject y entonces recibe el tráfico de room B)
|
||
```
|
||
|
||
**Estado del residual H4.** El aislamiento por subject derivado de pertenencia está **implementado y demostrado** a nivel de data plane: un peer registrado ya no puede sub/pub en subjects arbitrarios, queda confinado a las rooms a las que pertenece, y el contenido de las demás (ya E2E desde el hardening) le es invisible incluso como metadata de subject. El authenticator ACL es **opt-in** (`NewServer`/`membershipd` mantienen el authenticator abierto por defecto) y se cablea con el boot path decentralized. Lo que queda para 0003f/futuro: el **refresco automático y transparente** de permisos en cada cambio de membresía — hoy `RefreshSession` es una llamada explícita que el peer hace tras unirse a una room (porque NATS congela permisos al conectar, el flujo "connect → create → publish en la misma conexión" requiere un refresh entre el create y el publish). Esto es deliberado para no romper el flujo legacy ni los tests existentes; se activa con el rediseño de sesión del deploy.
|
||
|
||
---
|
||
|
||
## Cobertura de tests (DoD)
|
||
|
||
| Fase | Tests nuevos | Tipo |
|
||
|---|---|---|
|
||
| 0003a | `TestCluster*` (5) + `TestClusterConfigPolicy` + `TestSplitRoutes` | golden + 2 edge + 2 error path |
|
||
| 0003b | `TestJetStreamStore*` (4) | golden + edges + fail-closed |
|
||
| 0003c | `TestMigrate*` (2) + `TestBackup*` + smoke binario | paridad + idempotencia + backup |
|
||
| 0003d | `TestObjectStore*` (3) | golden + edge + contrato |
|
||
| 0003e | `TestClientFailoverAcrossNodes`, `TestReplicatedNonceRejectsCrossNodeReplay`, `TestSubjectACLIsolation`, `TestRefreshSessionGainsNewRoom` | failover + replay + aislamiento + refresh |
|
||
|
||
Todo el suite previo sigue verde: el comportamiento de un solo nodo (SQLite + disco, authenticator abierto, nonce en memoria) no cambia.
|
||
|
||
---
|
||
|
||
## Resumen para 0003f (despliegue multi-nodo — lo ejecuta el humano)
|
||
|
||
Pre-requisitos del issue: alta del alias SSH + clave de cada nodo, integración en la WireGuard, y revisar/aligerar la carga existente (magnus corre coolify/minio/postgres/etc.). Nodos hoy: **magnus** y **homer** (`141.94.69.66`); falta el **tercero** para quorum real.
|
||
|
||
### Despliegue R1 (2 nodos, magnus + homer — funciona, sin tolerancia a fallo todavía)
|
||
|
||
1. **CA y certificados de nodo.** Generar (o reusar) la CA del 0001 (`deploy/tls/ca.crt` + `ca.key` custodiada **fuera** de los hosts de aplicación, en `pass`). Emitir un certificado de **route** por nodo cuyo SAN cubra la IP/host de su puerto de cluster, firmado por esa CA (sirve para server y client auth en el handshake mutuo de routes).
|
||
2. **Secreto de cluster.** Un `--cluster-user`/`--cluster-pass` compartido por los dos nodos (en `pass`).
|
||
3. **Arrancar cada `membershipd`** con bind público + `--bus-auth enforce` + TLS del data plane (0001) + cluster:
|
||
```
|
||
membershipd --bind <ip> --bus-auth enforce \
|
||
--tls-cert deploy/tls/server.crt --tls-key deploy/tls/server.key \
|
||
--cluster-name unibus --server-name <magnus|homer> --cluster-port 6250 \
|
||
--cluster-user "$CL_USER" --cluster-pass "$CL_PASS" \
|
||
--route-tls-cert deploy/tls/<node>.crt --route-tls-key deploy/tls/<node>.key --route-tls-ca deploy/tls/ca.crt \
|
||
--routes nats://$CL_USER:$CL_PASS@<otro-nodo>:6250
|
||
```
|
||
`validateClusterConfig` exige el secreto + TLS mutuo completo en bind público (rechaza arrancar inseguro).
|
||
4. **systemd `Restart=always`** en cada nodo (un SIGTERM limpio es exit 0; `on-failure` no reiniciaría — gotcha conocido). Reusar `deploy/unibus-membershipd.service` + `deploy/install.sh`.
|
||
5. **Streams/KV/Object Store en R1** mientras solo haya 2 nodos (réplicas=1): el data plane efímero (core-NATS) ya tolera la caída de uno (los clientes reconectan al otro); el control plane KV y las rooms persistentes NO toleran caída hasta R3.
|
||
6. **Clientes:** pasarles la lista de los 2 endpoints — `Options{NatsServers: [n2], CtrlURLs: [ctrl2]}` además del primario — para que el failover de cliente (0003e) funcione.
|
||
|
||
> Nota sobre el flag `decentralized`: en R1 con 2 nodos se puede operar el control plane aún en SQLite por-nodo (cada `membershipd` su BD) si no se quiere KV todavía; para que cualquier nodo sirva cualquier request hay que activar el control plane KV (`decentralized: on`) — eso requiere completar el wiring de arranque KV (selección del store + bootstrap del authenticator interno) que esta entrega dejó documentado como el paso de integración de 0003f. La migración de los datos existentes la hace `membershipd migrate-to-kv` (probado) **antes** de activar el flag.
|
||
|
||
### Escalado a R3 (cuando entre el tercer nodo — HA real)
|
||
|
||
1. Preparar el tercer nodo (SSH + WireGuard + cert de route firmado por la CA).
|
||
2. Añadirlo al cluster (su `--routes` apunta a magnus+homer y viceversa; mismo `--cluster-name` y secreto).
|
||
3. Escalar en caliente, sin downtime ni migración de datos:
|
||
```
|
||
nats stream update <stream> --replicas 3
|
||
nats kv update <bucket> --replicas 3 # rooms/members/rooms_by_member/room_keys/users/nonces
|
||
nats obj update UNIBUS_blobs --replicas 3
|
||
```
|
||
o crear los buckets con `Replicas: 3` desde el inicio en el tercer nodo (el código ya lo soporta vía `JetStreamConfig.Replicas` / `ObjectStoreConfig.Replicas`).
|
||
4. Activar `decentralized: on` en `dev/feature_flags.json` (con el wiring de arranque KV completo).
|
||
5. **Chaos test real (DoD del issue):** matar 1 de 3 nodos → el control plane sigue aceptando escrituras (quorum 2/3); matar 2 de 3 → las escrituras se bloquean (quorum perdido, comportamiento esperado y documentado, no corrupción).
|
||
|
||
### No olvidar en 0003f
|
||
|
||
- **Nonce replicado obligatorio antes de exponer el cluster:** llamar `Server.UseReplicatedNonces(js, replicas)` en cada nodo. Sin esto el anti-replay es nulo en multi-nodo (un replay a otro nodo se acepta). Probado en `TestReplicatedNonceRejectsCrossNodeReplay`.
|
||
- **ACL por subject:** si se quiere el aislamiento de data plane en producción, usar `busauth.NewNkeyAuthenticatorACL(store.IsAuthorized, perms)` con `perms` derivado de `membership.SubjectACLFor(store)`, y que los clientes llamen `RefreshSession()` tras unirse a una room. Si no se activa, el residual sigue siendo: un peer registrado observa metadata de tráfico (no contenido) de subjects que conozca y puede spamear bytes descartados — irrelevante en un despliegue solo-WireGuard.
|
||
|
||
---
|
||
|
||
## Gaps / pendientes honestos de este trabajo
|
||
|
||
- **No hay despliegue real ni chaos test sobre 3 VPS** — todo se valida in-process (varios `nats-server` embebidos en un proceso). El chaos test real (matar 1/3, matar 2/3) es 0003f.
|
||
- **El wiring de arranque `membershipd`-en-decentralized no se cableó** (selección del store/blobstore KV + bootstrap del authenticator interno). El `jetstreamStore`, el `objectStore`, el `kvNonceStore` y el authenticator ACL están completos y testeados, pero el binario solo los expone como API; conectarlos en `main.go` (con el flag) pertenece a la integración de 0003f por el ciclo bootstrap descrito. Es branch-by-abstraction correcto (flag off, master verde), pero significa que `--decentralized` no es todavía un interruptor del binario.
|
||
- **KV no es transaccional:** `CreateRoom`/`AddMember` son secuencias de Puts idempotentes; una caída a mitad deja un estado parcial recoverable (no corrupto), no atómico. Aceptable para el control plane (baja frecuencia, owner-serializado).
|
||
- **El refresco de ACL no es transparente:** `RefreshSession` es una llamada explícita; el auto-refresh en cada cambio de membresía (rehaciendo suscripciones) queda para el rediseño de sesión.
|
||
- **No se ejecutó `govulncheck`** sobre nats-server/nats.go/modernc (paso de CI aparte, ya anotado en el report 0005).
|