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.
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 + flagdecentralized: 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/embeddednatsarranca un NATS standalone (sin cluster).pkg/membershipguarda rooms/members/room_keys/users en una SQLite local al proceso.pkg/blobstoreguarda 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- 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
membershipdsirve cualquier request porque el estado está en el KV replicado.
- 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
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-*)
- 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. - 0003b — interfaz Store + jetstreamStore (KV) — abstraer
pkg/membership.Store; implementar rooms/members/room_keys/users sobre JetStream KV R3; tests de consistencia. Flagdecentralized: off. - 0003c — migrate-to-kv — comando idempotente SQLite → KV + test de paridad (mismo estado antes/después).
- 0003d — blobs en Object Store — impl
pkg/blobstoresobre NATS Object Store replicado. - 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.
- 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
- 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.
- 0003 (este) después: una vez el bus es seguro, replicarlo en 3 nodos.
- 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.