# 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-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

- **N3** → `TestAttack0008_N3`: replay del mismo `ts+nonce` al 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 bindear `KV_UNIBUS_users` ni subscribirse a `$KV.UNIBUS_users.>` (permissions violation); golden: el owner sigue manejando el stream JetStream de SU room.
- **N1** → `TestAttack0008_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 **sí** 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.
