- 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>
9.5 KiB
Report 0009 — unibus: completar y endurecer el cluster (issue 0006, fases 0006a–0006g)
- Fecha: 07/06/2026
- Autor: agente (Claude Opus 4.8)
- Ámbito:
projects/message_bus/apps/unibus(sub-repodataforge/unibus),master. unibus 0.7.0 → 0.8.0. - Estado: done — 7 fases implementadas, mergeadas a master (merge
--no-ffpor fase), issue 0006 cerrado. - Origen: issue
dev/issues/0006-cluster-hardening-and-wiring.md, derivado de la auditoría report0008-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
- N3 →
TestAttack0008_N3: replay del mismots+nonceal nodo B da 401 (antes 200+200). Edge single-node y fail-fast clustered cubiertos. - N2 →
TestAttack0008_N2: eve (registrada, miembro de ninguna room) no puede bindearKV_UNIBUS_usersni subscribirse a$KV.UNIBUS_users.>(permissions violation); golden: el owner sigue manejando el stream JetStream de SU room. - N1 →
TestAttack0008_N1: un nodo--cluster-namecon--bus-auth offes rechazado en arranque;/healthzexpone la posture para detectar un peer débil.
Gaps / pendientes (honesto)
- Seed del primer admin en KV bajo enforce — el
userCLI 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 sí funciona con el código actual: un bootstrap loopback no-auth (--store kv --bus-auth off --bind 127.0.0.1) +migrate-to-kvsobre el mismo store dir, luego arrancar la unit enforce. Mejora futura limpia:membershipd user add --store kv. Documentado endeploy/cluster/README.md. - 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).
- 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. - External NATS + cluster — el wiring del nonce/KV está plenamente probado para el server embebido (el target del deploy). Para
--nats-urlexterno se conecta como cliente plano (connectExternalJS); la auth/posture del NATS externo es responsabilidad del operador (no validada aquí). - 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:
- 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).
- 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/healthzde cada nodo. - 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. - Seed del admin por el procedimiento loopback-bootstrap del runbook;
migrate-to-kvsolo loopback/TLS. - 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.