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

90 lines
9.5 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.
# 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
- **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.