Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
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-repodataforge/unibus). Paquetesembeddednats,busauth,membership,blobstore,client;cmd/membershipd;dev/feature_flags.json. - Estado: done — entrega 0003a–0003e en
masterdel 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 membershipds 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)
- CA y certificados de nodo. Generar (o reusar) la CA del 0001 (
deploy/tls/ca.crt+ca.keycustodiada fuera de los hosts de aplicación, enpass). 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). - Secreto de cluster. Un
--cluster-user/--cluster-passcompartido por los dos nodos (enpass). - Arrancar cada
membershipdcon 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>:6250validateClusterConfigexige el secreto + TLS mutuo completo en bind público (rechaza arrancar inseguro). - systemd
Restart=alwaysen cada nodo (un SIGTERM limpio es exit 0;on-failureno reiniciaría — gotcha conocido). Reusardeploy/unibus-membershipd.service+deploy/install.sh. - 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.
- 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 (cadamembershipdsu 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 hacemembershipd migrate-to-kv(probado) antes de activar el flag.
Escalado a R3 (cuando entre el tercer nodo — HA real)
- Preparar el tercer nodo (SSH + WireGuard + cert de route firmado por la CA).
- Añadirlo al cluster (su
--routesapunta a magnus+homer y viceversa; mismo--cluster-namey secreto). - Escalar en caliente, sin downtime ni migración de datos:
o crear los buckets con
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 3Replicas: 3desde el inicio en el tercer nodo (el código ya lo soporta víaJetStreamConfig.Replicas/ObjectStoreConfig.Replicas). - Activar
decentralized: onendev/feature_flags.json(con el wiring de arranque KV completo). - 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 enTestReplicatedNonceRejectsCrossNodeReplay. - ACL por subject: si se quiere el aislamiento de data plane en producción, usar
busauth.NewNkeyAuthenticatorACL(store.IsAuthorized, perms)conpermsderivado demembership.SubjectACLFor(store), y que los clientes llamenRefreshSession()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-serverembebidos 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). EljetstreamStore, elobjectStore, elkvNonceStorey el authenticator ACL están completos y testeados, pero el binario solo los expone como API; conectarlos enmain.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--decentralizedno es todavía un interruptor del binario. - KV no es transaccional:
CreateRoom/AddMemberson 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:
RefreshSessiones 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ó
govulnchecksobre nats-server/nats.go/modernc (paso de CI aparte, ya anotado en el report 0005).