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

9.2 KiB
Raw Blame History

issue, title, status, created, closed, closed_by, domain, scope, depends_on, blocks, source
issue title status created closed closed_by domain scope depends_on blocks source
0006 Completar y endurecer el cluster — wiring del control plane KV + N1-N6 de la auditoría 0008 done 2026-06-07 2026-06-07 fases 0006a0006g (ver report 0009); unibus v0.8.0 security unibus (cmd/membershipd, pkg/membership, pkg/embeddednats, pkg/busauth, pkg/client) 0003 (completa su wiring), 0005 (hereda el bus single-node ya seguro) 0003f (deploy del cluster descentralizado) 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).