Files
unibus/dev/issues/0006-cluster-hardening-and-wiring.md
T
egutierrez 926b8e96af chore(0006): bump unibus to 0.8.0, close issue 0006 (cluster hardening + wiring)
All seven phases (0006a–0006g) merged: blockers N3 (replicated nonce) and N2
($JS.API.> KV leak) closed, decentralized KV store wired (--store kv), homogeneous
cluster posture enforced (N1), RefreshSession in all clients (N4), the lows
(secret out of argv, migrate guard, R1/CA docs), and the 3-node deploy material.

Full suite + every audit-0008 attack regression green; govulncheck 0 reachable.
See report 0009.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:33:03 +02:00

161 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 0006
title: Completar y endurecer el cluster — wiring del control plane KV + N1-N6 de la auditoría 0008
status: done
created: 2026-06-07
closed: 2026-06-07
closed_by: fases 0006a0006g (ver report 0009); unibus v0.8.0
domain: security
scope: unibus (cmd/membershipd, pkg/membership, pkg/embeddednats, pkg/busauth, pkg/client)
depends_on: 0003 (completa su wiring), 0005 (hereda el bus single-node ya seguro)
blocks: 0003f (deploy del cluster descentralizado)
source: projects/message_bus/reports/0008-2026-06-07-unibus-decentralization-audit.md
---
# Objetivo
La auditoría dedicada de la superficie de 0003 (report 0008) concluyó: **el bus en
cluster NO es seguro para público** por dos bloqueantes, y además **0003 dejó el
control plane descentralizado SIN cablear** (el binario sigue usando SQLite single-store;
el flag `decentralized` existe pero ningún código Go lo lee). Como nodo único standalone
unibus YA es seguro (report 0008 lo confirma); como cluster, no.
Este issue cierra los bloqueantes de seguridad del cluster Y completa el wiring que 0003
dejó a medias, de modo que el deploy descentralizado (0003f) sea seguro. Cada fase
reproduce el ataque del report 0008 (`TestAttack0008_*`) y verifica que ahora se rechaza.
# Fases (TBD, ramas `issue/0006x-*`)
## 0006a — N3 (BLOQUEANTE): cablear el nonce replicado en el binario
**Hallazgo (ALTA):** `membershipd` **nunca llama** `Server.UseReplicatedNonces`; cada nodo
usa `memNonceCache` por-proceso. Un request firmado aceptado en el nodo A se **replaya con
éxito en el nodo B** (200+200). La API (`kvNonceStore`) y el test
(`TestReplicatedNonceRejectsCrossNodeReplay`) existen, pero el binario no los invoca.
**Fix:** en `cmd/membershipd/main.go`, cuando se arranca con `--cluster-name` (o siempre que
haya JetStream disponible), llamar `srv.UseReplicatedNonces(js, replicas)` y **fail-fast** si
el bucket `KV_UNIBUS_nonces` no se crea. Regla dura: `--cluster-name != ""` ⇒ nonce replicado
**obligatorio** (no arrancar un nodo de cluster con nonce-cache local).
**DoD:** reproducir `TestAttack0008_N3` (2 nodos con el wiring exacto del binario) → el replay
del nonce al nodo B ahora da **401**. Golden (request normal OK en cualquier nodo) + edge
(single-node sin cluster sigue usando cache local OK) + error (replay cross-node → 401).
## 0006b — N2 (BLOQUEANTE): cerrar `$JS.API.>` / aislar el control plane KV
**Hallazgo (ALTA):** el grant ACL `clientInfraSubjects = {"_INBOX.>", "$JS.API.>"}`
(`acl.go:20`) deja a cualquier peer registrado leer los buckets KV del control plane
(`KV_UNIBUS_users/rooms/members/room_keys`) directo por NATS, saltándose `requireMember` y
los chequeos del HTTP. Fuga del allowlist (handles+roles+claves), grafo de rooms y metadata
de sealed-keys. (La ESCRITURA al KV ya está denegada — verificado; la fuga es de lectura.)
**Fix (elegir y documentar):**
- Sustituir el grant amplio `$JS.API.>` por permisos JetStream **mínimos por-room** (solo la
API del stream/consumer de las rooms del peer), y **denegar explícitamente** los streams
`KV_UNIBUS_*` y `OBJ_*`; o
- (Más robusto) aislar el control plane KV en una NATS **account separada**, inaccesible
desde la account de clientes.
**DoD:** reproducir `TestAttack0008_N2` → eve (registrada, no miembro) ya **NO** puede leer
los buckets KV (`Permissions Violation` o equivalente). La JetStream API legítima de las
rooms del peer sigue funcionando.
## 0006c — wiring del control plane KV (completar 0003)
**Hallazgo (MEDIA / raíz):** el binario no activa el store descentralizado. `membership.Open`
(SQLite) está hardcodeado en `main.go:90`; `OpenJetStream` solo lo usa `migrate-to-kv`.
**Fix:** leer el flag `decentralized` (o un `--store kv|sqlite`) y **seleccionar el store** en
el arranque: SQLite (default, single-node/dev) o `jetstreamStore` (cluster). Resolver el
"ciclo bootstrap" del authenticator interno (el authenticator necesita el store para
`IsAuthorized`, y el store KV necesita el NATS arrancado). Mantener branch-by-abstraction:
con el flag off, comportamiento idéntico al actual. `IsAuthorized`/lecturas sobre KV
**fail-closed** ante pérdida de quorum/timeout (ya implementado en `jetstreamStore`
verificar que el wiring lo preserva).
**DoD:** con `decentralized: on` + cluster, el control plane sirve desde el KV replicado y un
nodo nuevo ve las rooms creadas en otro (cierra la divergencia de estado que nota N5).
Fail-closed: simular KV no disponible → deniega. Con flag off, suite idéntica al baseline.
## 0006d — N1 (ALTA): posture homogénea del cluster
**Hallazgo:** el cluster es tan seguro como su nodo más débil; un nodo sin authenticator o
`--bus-auth off` deja a un peer no autenticado `Subscribe(">")` y cosechar el tráfico
reenviado de los nodos con ACL.
**Fix:** garantizar (en arranque/health) que todos los nodos corren `enforce`+ACL+TLS;
rechazar formar cluster con un peer en posture inferior, o como mínimo documentar y exponer
un health que lo detecte. Nunca exponer el puerto de cliente de un nodo sin enforce.
**DoD:** reproducir `TestAttack0008_N1` escenario 2 (cluster con un nodo `withACL=false`) →
el arranque/health lo rechaza o lo señala; documentar la garantía.
## 0006e — N4 (MEDIA): RefreshSession en los clientes
**Hallazgo:** la ACL congela permisos al conectar; un peer que crea/se une a una room debe
llamar `client.RefreshSession()` para pub/sub en su subject. **Ningún cliente lo llama**
(`cmd/chat`, `cmd/worker`, `mobile`, `gateway`). Es fail-closed (deniega), pero rompe la
usabilidad bajo `enforce`+ACL → empuja al operador a desactivar la ACL (regresión de
seguridad a discreción del operador).
**Fix:** llamar `RefreshSession` tras cambios de membresía en `cmd/chat`/`cmd/worker` (y
documentar el contrato para `mobile`/`gateway`), o implementar refresh transparente (rehacer
suscripciones automáticamente al unirse a una room).
**DoD:** test que crea/une room bajo enforce+ACL y publica/recibe SIN intervención manual
(el cliente refresca solo o el demo llama RefreshSession). Documentar el requisito.
## 0006f — bajos: CA de routes, secreto de cluster, migrate-to-kv, R1≠HA
- **N1 (BAJA):** CA **separada** para las routes del cluster (no reusar la CA del data plane
de clientes); pasar el secreto de cluster por **archivo/env**, no por `--routes
nats://user:pass@host` en argv (hoy visible en `ps`/`journald`).
- **N6 (BAJA):** `migrate-to-kv` solo en loopback o con TLS (hoy el allowlist viaja plaintext
si `--nats-url` remoto sin `--ca`).
- **N3-DoS (MEDIA, doc):** documentar que el nonce/control plane en **R1 es SPOF de auth** (su
caída rechaza todos los requests autenticados); R3 (quorum 2/3) es la condición de HA real.
No vender R1 como HA.
## 0006g — preparar el material de deploy del cluster (3 nodos)
Los tres nodos del cluster están decididos: **magnus + homer + datardos** (3 VPS OVH →
quorum R3 real, tolera la caída de uno). Datos: homer `141.94.69.66`; datardos `ssh dd`
`51.91.100.142` (WG `datardos-wg` 10.21.0.x); magnus en `pass` (`MAGNUS_ovh_ssh_ROOT`).
**Preparar (NO ejecutar en los VPS — eso es 0003f, lo hace el humano):** dejar en
`deploy/cluster/` el material parametrizado por nodo:
- `generate-cluster-certs.sh` — CA propia del cluster (separada de la de clientes, ver 0006f)
+ un server cert por nodo con SAN = su IP pública + su IP WG + hostname.
- una plantilla de systemd unit por nodo (`membershipd@.service` o tres units) con
`--bind 0.0.0.0 --bus-auth enforce --tls-cert … --cluster-name unibus --routes
nats://…@<otros-2-nodos> --store kv` y `Restart=always`, secreto de cluster por archivo/env.
- `deploy-cluster.sh` (cross-build linux + rsync por nodo + plan de arranque escalonado).
- un `README.md` con el runbook: orden de arranque, seed del admin, `migrate-to-kv` (loopback/TLS),
escalado de réplicas a R3 (`nats stream update --replicas 3`), verificación de quorum y chaos
test (matar un nodo). Marcar claramente qué pasos toca el humano.
**DoD:** el material existe y es coherente (los certs cubren los 3 nodos; las units referencian
los routes correctos); un `bash -n` de los scripts pasa; el README describe el deploy end-to-end.
NO se toca ningún VPS desde el agente.
# Fuera de alcance (otros issues / operacional)
- **H8** (CA generada/custodiada en om) → operacional del deploy 0003f.
- **H9/H10/H11** → issue 0002 / futuro.
- **Object Store (blobs) vía `$JS.API.>`**: el report 0008 lo marca como "probable misma
clase que N2, no verificado" (impacto menor: blobs son ciphertext E2E). El fix de 0006b
(denegar `OBJ_*`) lo cubre; verificar.
- **Chaos test de red real** (matar 1/3, 2/3, split-brain) → 0003f (requiere 3 VPS).
# Definition of Done global
- `TestAttack0008_N3` → replay cross-node **401**; `TestAttack0008_N2` → eve no lee buckets KV;
`TestAttack0008_N1` → nodo débil rechazado/señalado. Portados como regresión.
- Con `decentralized: on`: control plane sobre KV replicado, fail-closed verificado, estado
consistente entre nodos. Con flag off: baseline idéntico.
- Clientes operan bajo `enforce`+ACL sin intervención manual (RefreshSession resuelto).
- `CGO_ENABLED=0 go build ./... && go vet ./... && go test ./...` verdes + `govulncheck` 0.
- Veredicto re-evaluado: el bus DESCENTRALIZADO pasa de "NO" a "sí-con-condiciones" (3 nodos
R3 para HA real, posture homogénea, CA en om).