Files
unibus/dev/issues/0003-decentralization-ha.md
T
agent bcd02716d5 docs(issues): encolar 0002 (media v2), 0003 (descentralización HA), 0004 (hardening seguridad)
Specs de los tres issues siguientes del bus, derivados de esta sesión:
- 0002 media v2: chunking, mimetype, GC del object store, exponer en clientes.
- 0003 descentralización/HA: cluster NATS magnus+homer (R1→R3), control plane
  SQLite→JetStream KV, quorum, failover. Tercer nodo = homer (141.94.69.66).
- 0004 hardening: cierra los hallazgos de la auditoría red-team (report 0004):
  DoS pre-auth, fail-open, autorización por pertenencia, ACL NATS, TLS control plane.
2026-06-07 14:04:33 +02:00

10 KiB

issue, title, status, created, domain, scope, depends_on
issue title status created domain scope depends_on
0003 Descentralización / alta disponibilidad — cluster NATS + JetStream replicado + control plane sin SPOF spec 2026-06-07 infra unibus (pkg/embeddednats, pkg/membership, pkg/blobstore, pkg/client, cmd/membershipd) + despliegue multi-nodo 0001 (la auth de cluster y de clientes va junto con el endurecimiento)

Objetivo

Que la caída de un servidor no deje el bus sin servicio. Hoy unibus es un único membershipd (con NATS embebido + SQLite local): si ese host muere, no hay bus. Este issue lleva unibus a un modelo descentralizado / alta disponibilidad usando las capacidades nativas de NATS: cluster multi-nodo, JetStream replicado (RAFT), y el estado del control plane fuera de la SQLite local. No es federación (multi-operador con dominios distintos); es eliminar el punto único de fallo dentro de un único dominio administrativo controlado por nosotros.

Requisito clave de quorum (decisión de infraestructura)

JetStream replica con RAFT, que necesita mayoría (quorum) para confirmar escrituras. Las consecuencias son duras y hay que asumirlas desde el diseño:

Nodos Réplica Tolera caída de Nota
1 R1 0 situación actual (SPOF)
2 R2 0 si cae uno se pierde quorum: las escrituras se bloquean. NO sirve para HA
3 R3 1 mínimo real para "si un server cae, seguimos"
5 R5 2 mayor tolerancia

Por tanto el objetivo del usuario ("si mi server falla, no nos quedamos sin servicio") exige 3 nodos JetStream. Servers disponibles hoy: magnus y homer (ambos VPS OVH). El tercero está pendiente de conseguir.

Nodo IP pública Estado Notas
magnus (en pass: MAGNUS_ovh_ssh_ROOT) disponible, cargado corre coolify, minio, postgres, authentik, portainer, dagu — revisar recursos antes
homer 141.94.69.66 disponible, vivo creds en pass (vps_ovhcloud_SSH_SERVER_HOMER_-_root, vps_SSH_SERVER_HOMER_dataherrero); tenía coolify
nodo 3 pendiente conseguir un tercer VPS siempre-on, o reusar om/datardos si se liberan

Preparación previa al deploy de cada nodo: alta del alias SSH + clave, integración en la WireGuard, y revisar/aligerar la carga existente (coolify, etc.).

Rollout R1 → R3: funcionar con 2 nodos hoy, HA con 3 mañana

No se "desactiva el quorum"; se controla el número de réplicas de cada stream/KV:

Réplicas Quorum Tolera Sirve con
R1 ninguno (1 copia) 0 caídas 1-2 nodos, sin bloqueo
R3 2 de 3 1 caída 3 nodos
  • Fase actual (magnus + homer): desplegar con streams/KV en R1 (flag decentralized: off). El bus funciona al 100% para operar, sin tolerancia a fallo todavía. Opción: streams en R2 para duplicar los datos en ambos nodos (durabilidad/backup vivo), asumiendo que la escritura necesita los dos hasta el 3er nodo.
  • Cuando entre el nodo 3: escalar en caliente nats stream update --replicas 3 (idem KV/Object Store) + añadir el nodo al cluster + flag decentralized: on. HA real, sin downtime, sin reescritura, sin migrar datos.
  • Aviso de 2 nodos: NO montar el meta-group de JetStream con 2 nodos como si fuera HA — su quorum es 2, y la caída de uno bloquea la gestión de streams. Con 2 servers, modelo recomendado: magnus principal (R1) + homer 2º nodo/réplica, y escalar a R3 al tener el tercero.

Mientras solo haya 2 nodos: el data plane efímero (core-NATS, rooms ModeNATS) sí tolera la caída de uno (los clientes reconectan al otro), pero las rooms persistentes y el control plane (que necesitan quorum) no. El issue se despliega de verdad cuando haya 3 nodos.

Contexto — por qué hoy es un SPOF

  • pkg/embeddednats arranca un NATS standalone (sin cluster).
  • pkg/membership guarda rooms/members/room_keys/users en una SQLite local al proceso.
  • pkg/blobstore guarda los blobs en el disco local del proceso.
  • El cliente (pkg/client) conecta a una URL de NATS y una de control plane.

Todo vive en un host. Ese host es el punto único de fallo.

Diseño

Pieza 1 — Cluster NATS (data plane replicado)

pkg/embeddednats gana opciones de cluster: server.Options.Cluster (nombre + host/puerto de routes) y Routes (los otros nodos). Cada membershipd arranca su NATS embebido en cluster con los demás. JetStream se habilita con Replicas: 3 en streams y KV. Auth entre nodos (routes) con credenciales propias (no las de clientes), y TLS también en las routes (reusa la CA del issue 0001).

Pieza 2 — Control plane sin estado local (SQLite → JetStream KV)

Es el corazón del issue. Hoy pkg/membership.Store es SQLite. Se introduce, por branch-by-abstraction, una interfaz Store con dos implementaciones:

  • sqliteStore — la actual (sigue siendo el default mientras el flag está off; útil para un solo nodo / desarrollo).
  • jetstreamStore — nueva: rooms, members, room_keys y users (la tabla del issue
    1. viven en JetStream KV (buckets replicados R3). Cualquier nodo lee/escribe el mismo estado; RAFT garantiza consistencia. El HTTP control plane pasa a ser efectivamente stateless: cualquier membershipd sirve cualquier request porque el estado está en el KV replicado.

Flag decentralized (off → on). Migración inicial de datos SQLite → KV con un comando membershipd migrate-to-kv (idempotente). Las claves de room siguen selladas igual; solo cambia dónde se guardan, no el cifrado.

Pieza 3 — Blobs replicados (object store → NATS Object Store)

pkg/blobstore gana una implementación sobre NATS Object Store (encima de JetStream, replicado R3) además de la de disco local. Los blobs (ya ciphertext, E2E) quedan disponibles desde cualquier nodo. Encaja con el GC del issue 0002.

Pieza 4 — Cliente con failover

pkg/client: aceptar lista de seeds de NATS y lista de URLs de control plane. nats.go ya hace reconnect/failover entre servidores del cluster nativamente (nats.Servers([...]), nats.MaxReconnects(-1)). El control plane HTTP se prueba en orden con reintento. Así, si un nodo cae, el cliente reconecta a otro de forma transparente.

Pieza 5 — Despliegue multi-nodo

3 nodos membershipd, cada uno con su NATS embebido en cluster, JetStream R3, mismo ca.crt/credenciales de routes. systemd en cada VPS. Los clientes reciben la lista de los 3 endpoints. Health/observabilidad por nodo (/healthz + métricas de JetStream: líder RAFT, lag de réplica).

Decisiones técnicas

Decisión Elegido Alternativa Razón
Nº de nodos de quorum 3 (R3) 2 (R2) 2 no tolera caída de uno; 3 es el mínimo real de HA
Estado del control plane JetStream KV replicado SQLite replicada a mano / Postgres externo KV ya viene con NATS, mismo RAFT que JetStream, cero infra extra
Migración del store branch-by-abstraction (interfaz Store, dos impls, flag) reescritura directa master nunca se rompe; sqlite sigue para 1 nodo/dev
Blobs NATS Object Store disco compartido / S3 replicado nativamente, sin dependencia externa
Failover de cliente lista de seeds + reconnect nativo nats.go balanceador externo menos infra, nats.go ya lo hace
Federación multi-operador fuera de alcance no es el objetivo; es otra liga (trust entre dominios)

Fases (TBD, ramas issue/0003x-*)

  1. 0003a — cluster NATS — opciones de cluster/routes + TLS de routes en pkg/embeddednats; arrancar 2-3 nodos locales en tests e2e y verificar que un subject publicado en uno llega a un suscriptor en otro.
  2. 0003b — interfaz Store + jetstreamStore (KV) — abstraer pkg/membership.Store; implementar rooms/members/room_keys/users sobre JetStream KV R3; tests de consistencia. Flag decentralized: off.
  3. 0003c — migrate-to-kv — comando idempotente SQLite → KV + test de paridad (mismo estado antes/después).
  4. 0003d — blobs en Object Store — impl pkg/blobstore sobre NATS Object Store replicado.
  5. 0003e — cliente failover — lista de seeds + lista de ctrl-urls + reconnect; test que mata el nodo al que está conectado y verifica que sigue operando.
  6. 0003f — despliegue 3 nodos (humano) — 3 VPS en cluster, JetStream R3, flag decentralized: on. Chaos test real: matar un nodo en producción y comprobar que el servicio sigue.

Definition of Done (evidencia ejecutable)

  • Golden: 3 nodos en cluster; un cliente publica en un nodo y otro cliente suscrito a otro nodo lo recibe; crear room + invitar funciona desde cualquier nodo.
  • Edge: un cliente conectado al nodo A; se mata el nodo A; el cliente reconecta a B automáticamente y sigue publicando/recibiendo sin perder la sesión.
  • Error path (chaos): 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).
  • CGO_ENABLED=0 go test ./... verde, incluido un test e2e multi-nodo en proceso.

Riesgos y mitigaciones

Riesgo Mitigación
Solo 2 nodos disponibles → sin quorum real prerequisito explícito de 3 nodos antes de 0003f; hasta entonces, despliegue queda en standalone
Latencia inter-VPS afecta RAFT nodos en la misma región o con buena red; medir; R3 tolera latencias moderadas
Migración SQLite→KV pierde datos comando idempotente + test de paridad + backup de la SQLite antes
Partición de red (split-brain) RAFT lo previene: el lado sin quorum se bloquea para escritura, no diverge
Complejidad operativa de 3 nodos observabilidad de JetStream (líder, lag) + /healthz por nodo + runbook en deploy/

Orden recomendado respecto a otros issues

  1. 0001 (seguridad) primero: la auth de clientes (nkey) y la CA/TLS se reutilizan para las routes del cluster. Desplegar descentralizado sin auth sería abrir varios puntos públicos sin protección.
  2. 0003 (este) después: una vez el bus es seguro, replicarlo en 3 nodos.
  3. 0002 (media v2) es ortogonal; su object store encaja con la pieza 3 (blobs replicados) cuando ambos estén.

Fuera de alcance

  • Federación entre operadores/dominios distintos (otra liga; requiere protocolo de trust entre dominios).
  • Multi-tenant / accounts de NATS por organización.
  • Auto-escalado dinámico de nodos.