Files
message_bus/reports/0009-2026-06-07-unibus-cluster-hardening.md
T
egutierrez d43ffae3ae chore: auto-commit (17 archivos)
- reports/0001-2026-06-07-unibus-grafana-monitoring.md
- reports/0008-2026-06-07-unibus-admin-users-wired.md
- reports/0008-2026-06-07-unibus-decentralization-audit.md
- reports/0009-2026-06-07-unibus-cluster-hardening.md
- reports/0010-2026-06-07-unibus-android-native.md
- reports/0011-2026-06-07-unibus-cluster-deploy.md
- reports/0012-2026-06-07-unibus-deploy-gaps-closed.md
- reports/0013-2026-06-07-unibus-admin-panel.md
- reports/0014-2026-06-07-unibus-users-http-admin-api.md
- reports/0015-2026-06-07-unibus-web-wired.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 01:57:00 +02:00

9.5 KiB
Raw Blame History

Report 0009 — unibus: completar y endurecer el cluster (issue 0006, fases 0006a0006g)

  • Fecha: 07/06/2026
  • Autor: agente (Claude Opus 4.8)
  • Ámbito: projects/message_bus/apps/unibus (sub-repo dataforge/unibus), master. unibus 0.7.0 → 0.8.0.
  • Estado: done — 7 fases implementadas, mergeadas a master (merge --no-ff por fase), issue 0006 cerrado.
  • Origen: issue dev/issues/0006-cluster-hardening-and-wiring.md, derivado de la auditoría report 0008-2026-06-07-unibus-decentralization-audit.md.

Resumen

La auditoría 0008 concluyó que el bus en cluster NO era seguro (2 bloqueantes) y que 0003 dejó el control plane descentralizado sin cablear (el binario seguía en SQLite single-store; el flag decentralized no lo leía nadie). Este trabajo cierra los dos bloqueantes (N3 replay cross-node, N2 fuga del control plane por $JS.API.>), cablea el store KV y el nonce replicado, fuerza posture homogénea (N1), hace usable la ACL bajo enforce (N4 RefreshSession), endurece los bajos (secreto fuera de argv, migrate guard, CA de routes separada, R1≠HA) y deja el material de deploy de los 3 nodos (magnus+homer+datardos) listo sin tocar ningún VPS. Branch-by-abstraction: con --store sqlite (default) el single-node es idéntico al baseline v0.7.0 y siempre desplegable.

Cambios por fase (cada fase = una rama issue/0006x-*, merge --no-ff)

Fase Bloqueante / vector Cambio Archivos clave
0006a N3 (BLOQUEANTE) — replay cross-node El binario cablea el nonce KV replicado: --cluster-name != ""srv.UseReplicatedNonces(js, replicas) obligatorio (fail-fast). Ciclo bootstrap resuelto con identidad interna efímera (NewNkeyAuthenticatorACLInternal + fullPermissions) y conexión in-process privilegiada. pkg/busauth/authenticator.go, cmd/membershipd/{internal_conn,wiring,main}.go
0006b N2 (BLOQUEANTE) — fuga del control plane ACL pasa de {_INBOX.>, $JS.API.>} a allow-set cerrado por-room: JS API solo de los streams UNIBUS_<room> del peer (jsSubjectsFor). KV_UNIBUS_*/OBJ_* quedan fuera del set → denegados. Clientes acceden a blobs por HTTP, no por NATS object store. pkg/membership/acl.go
0006c wiring KV (raíz) Flag --store kv|sqlite (default sqlite). kv abre OpenJetStream sobre la conexión interna; storeHolder fail-closed rompe el ciclo bootstrap del authenticator. cmd/membershipd/{store_holder,main}.go, dev/feature_flags.json
0006d N1 (ALTA) — posture validateClusterConfig exige enforce si --cluster-name != "" (un nodo débil no se une). /healthz publica Server.Posture {enforce,acl,tls,cluster,store}. cmd/membershipd/config.go, pkg/membership/server.go
0006e N4 (MEDIA) — RefreshSession cmd/chat, cmd/worker, local_files/bridge y mobile llaman RefreshSession tras cambios de membresía; contrato documentado para mobile/gateway. cmd/{chat,worker}/main.go, local_files/bridge/main.go, mobile/unibus.go
0006f bajos (N1/N6 + R1 doc) Secreto de cluster fuera de argv (--cluster-pass-file/UNIBUS_CLUSTER_PASS + inyección de creds en routes); migrate-to-kv rechaza target remoto sin --ca; docs CA routes separada + R1 SPOF vs R3 HA. cmd/membershipd/{config,migrate_cli,main}.go, deploy/README.md
0006g material de deploy deploy/cluster/: generate-cluster-certs.sh (CA de cluster separada + cert por nodo SAN público+WG+hostname), membershipd-cluster.service (unit parametrizada por cluster.env), deploy-cluster.sh (cross-build + rsync, dry-run por defecto), README.md runbook. NO toca VPS. deploy/cluster/*

Verificación (evidencia ejecutable)

Regresión de los ataques del report 0008 + tests de wiring nuevos

$ CGO_ENABLED=0 go test -count=1 -v -run 'TestAttack0008|TestInternalConn|TestStoreHolder|TestKVStore|TestHealthExposesPosture|TestClientCreateRoomRefreshPublishFlow|TestResolveClusterPass|TestInjectRouteCreds|TestIsLoopbackURL' ./cmd/membershipd/ ./pkg/membership/
--- PASS: TestAttack0008_N3                       (replay cross-node -> 401; wiring del binario)
--- PASS: TestAttack0008_N3_StandaloneKeepsLocalCache (single-node sin cluster: cache local OK)
--- PASS: TestAttack0008_N3_ClusteredRequiresJetStream (clustered sin JS -> fail-fast)
--- PASS: TestAttack0008_N1                       (nodo clustered sin enforce -> rechazado)
--- PASS: TestAttack0008_N2                       (eve no lee buckets KV; golden room JS OK)
--- PASS: TestInternalConnPrivilegedUnderEnforce  (bootstrap: internal id full-perms bajo enforce)
--- PASS: TestInternalConnOutsiderRejected        (outsider rechazado)
--- PASS: TestKVStoreBootstrapUnderEnforce        (store KV autoriza clientes bajo enforce)
--- PASS: TestKVStoreDecentralizedConsistency     (un nodo ve rooms creadas en otro)
--- PASS: TestStoreHolderFailClosed               (holder vacío deniega; sirve tras set)
--- PASS: TestHealthExposesPosture                (/healthz publica posture)
--- PASS: TestClientCreateRoomRefreshPublishFlow  (create->refresh->sub->pub bajo enforce+ACL)
--- PASS: TestResolveClusterPass / TestInjectRouteCreds / TestIsLoopbackURL
ok  github.com/enmanuel/unibus/cmd/membershipd
ok  github.com/enmanuel/unibus/pkg/membership

Suite completa + toolchain (master, HEAD tras los 7 merges)

$ CGO_ENABLED=0 go build ./...   # OK
$ CGO_ENABLED=0 go vet ./...     # limpio
$ CGO_ENABLED=0 go test -count=1 ./...
ok  cmd/membershipd ; ok pkg/blobstore ; ok pkg/busauth ; ok pkg/client
ok  pkg/embeddednats ; ok pkg/frame ; ok pkg/membership

$ go run golang.org/x/vuln/cmd/govulncheck@latest ./...
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
(0 alcanzables; 13 en módulos requeridos pero no llamadas por el código)

Regresión de auditorías previas (0001/0004/0005 + reaudit) sigue verde — la corrida completa incluye TestAudit_*, TestReaudit_*, TestReplicatedNonce*, TestSubjectACL*, TestClientFailover*. bash -n de deploy/cluster/{generate-cluster-certs,deploy-cluster}.sh pasa.

DoD por bloqueante

  • N3TestAttack0008_N3: replay del mismo ts+nonce al nodo B da 401 (antes 200+200). Edge single-node y fail-fast clustered cubiertos.
  • N2TestAttack0008_N2: eve (registrada, miembro de ninguna room) no puede bindear KV_UNIBUS_users ni subscribirse a $KV.UNIBUS_users.> (permissions violation); golden: el owner sigue manejando el stream JetStream de SU room.
  • N1TestAttack0008_N1: un nodo --cluster-name con --bus-auth off es rechazado en arranque; /healthz expone la posture para detectar un peer débil.

Gaps / pendientes (honesto)

  1. Seed del primer admin en KV bajo enforce — el user CLI escribe solo en SQLite, y bajo enforce ninguna herramienta externa puede escribir el primer admin al KV (chicken-and-egg de auth: para escribir hay que ser admin). El runbook documenta el procedimiento que funciona con el código actual: un bootstrap loopback no-auth (--store kv --bus-auth off --bind 127.0.0.1) + migrate-to-kv sobre el mismo store dir, luego arrancar la unit enforce. Mejora futura limpia: membershipd user add --store kv. Documentado en deploy/cluster/README.md.
  2. Chaos test de red real (matar 1/3, 2/3, partición/split-brain RAFT) — requiere los 3 VPS; es 0003f. Aquí solo se razonó el quorum + se probó la consistencia in-process (dos stores, mismos buckets).
  3. Object Store (blobs) vía NATS — los clientes acceden a blobs por HTTP, no por el object store NATS, así que OBJ_UNIBUS_* queda fuera del allow-set de clientes (cerrado por la misma regla que N2). No se añadió un test de ataque dedicado para OBJ (el cliente nunca lo toca por NATS); cubierto por construcción.
  4. External NATS + cluster — el wiring del nonce/KV está plenamente probado para el server embebido (el target del deploy). Para --nats-url externo se conecta como cliente plano (connectExternalJS); la auth/posture del NATS externo es responsabilidad del operador (no validada aquí).
  5. R1 = SPOF de auth — documentado, no "resuelto": R1 no es HA. La condición de HA real es R3 (quorum 2/3), que es un paso operativo (nats stream update --replicas 3) del runbook.

Veredicto — ¿el bus DESCENTRALIZADO es seguro para 0003f?

Sí, con condiciones. Los dos bloqueantes del report 0008 (N3, N2) están cerrados y portados como regresión; el control plane descentralizado está cableado (--store kv) y es fail-closed; la posture homogénea se fuerza en arranque y se observa en /healthz; la ACL es usable bajo enforce sin desactivarla. Condiciones para el deploy 0003f:

  1. 3 nodos en R3 (magnus+homer+datardos) para HA real — arrancar en R1 y escalar a R3 en sitio antes de declarar HA (R1 es SPOF de auth).
  2. Posture homogénea en los 3 nodos: enforce + per-subject ACL + TLS de datos + mutual route TLS. El binario rechaza unirse al cluster sin enforce; verificar /healthz de cada nodo.
  3. CA de routes separada de la de clientes (la genera deploy/cluster/generate-cluster-certs.sh); secreto de cluster por archivo/env, nunca en argv.
  4. Seed del admin por el procedimiento loopback-bootstrap del runbook; migrate-to-kv solo loopback/TLS.
  5. Pendiente de validar en 0003f: el chaos test de red sobre los VPS reales (kill 1/3 tolera; kill 2/3 debe fail-closed, no fail-open).

El nodo único standalone (--store sqlite, enforce+TLS) permanece seguro y desplegable en todo momento (branch-by-abstraction): este trabajo no lo altera.