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

196 lines
10 KiB
Markdown

---
issue: 0003
title: Descentralización / alta disponibilidad — cluster NATS + JetStream replicado + control plane sin SPOF
status: spec
created: 2026-06-07
domain: infra
scope: unibus (pkg/embeddednats, pkg/membership, pkg/blobstore, pkg/client, cmd/membershipd) + despliegue multi-nodo
depends_on: 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
0001) 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.