- 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>
14 KiB
Report 0012 — Cierre de los gaps del despliegue del cluster unibus (report 0011)
- Fecha: 07/06/2026
- Autor: agente (Claude)
- Ámbito:
projects/message_bus/apps/unibus/(sub-repodataforge/unibus) — cierre de los gaps que el despliegue del cluster de 3 nodos (report 0011) dejó abiertos. Ramaquick/0011-deploy-gaps(worktree/tmp/unibus_gaps, basada en master). - Estado: done — GAP A y GAP B cerrados con evidencia ejecutable contra el cluster VIVO (magnus + homer + datardos, R3); GAP C (runbook) corregido. Pendiente menor: rollout del binario nuevo a magnus+homer (documentado, sin urgencia — la capability está probada y desplegada en datardos).
Resumen
El report 0011 dejó tres gaps. Este trabajo los cierra: (A) membershipd user add|list|revoke --store kv añade usuarios al KV replicado con el cluster en marcha vía la conexión interna privilegiada — sin parar-sembrar-rearrancar; (B) cmd/clientcheck es la verificación end-to-end real del plano de datos (cliente autenticado nkey+TLS, room E2E, publish/subscribe, incluido failover con un nodo caído) que el chaos test del 0011 nunca ejecutó; (C) el runbook deploy/cluster/README.md queda corregido (orden de arranque, R1 inservible, nueva vía de alta). Todo verificado contra los 3 VPS reales con la posture enforce+ACL+TLS+R3 intacta. app.md sube 0.8.0 → 0.9.0.
Cambios
| Archivo | Qué | Por qué |
|---|---|---|
pkg/client/identity.go |
LoadIdentity (load-only) extraída de LoadOrCreateIdentity |
La CLI --store kv y el daemon cargan la identidad de servicio persistida; se preserva el guard "archivo corrupto = error, no se regenera". |
cmd/membershipd/main.go |
flag --internal-id-file |
Persiste la identidad interna privilegiada (load-or-create, 0600) en vez de efímera, para que la misma nkey esté disponible fuera de proceso. Vacío = efímero (default dev/single-node sin cambios). |
cmd/membershipd/users_kv.go |
connectKVStore + reportKVReplication (nuevo) |
Conexión privilegiada al NATS del cluster con la identidad interna; abre el store KV y escribe. Rechaza remoto sin --ca. Imprime followers_current tras escribir. |
cmd/membershipd/users_cli.go |
--store kv en add/list/revoke + idempotencia explícita |
Alta/baja contra el KV vivo. Re-alta de la misma clave = ErrUserExists (sin sobrescribir ni elevar rol). |
cmd/membershipd/kv_useradd_test.go (nuevo) |
tests de integración GAP A | Golden bajo enforce, idempotencia, endpoint muerto, remoto-sin-CA. |
cmd/clientcheck/ (nuevo) |
comando de verificación E2E reutilizable | GAP B: cliente real, room E2E, golden + loop (failover). |
deploy/cluster/README.md |
runbook corregido | GAP C: arranque por quórum, R1 SPOF→R3, vía de alta live, topología real. |
deploy/cluster/membershipd-cluster.service, deploy-cluster.sh |
--internal-id-file ${INTERNAL_ID_FILE} + INTERNAL_ID_FILE en cluster.env |
Para que un deploy fresco habilite la vía de alta live en todos los nodos. |
app.md |
0.8.0 → 0.9.0 + growth log | Nueva capability. |
Mecanismo de la conexión privilegiada (GAP A) — diseño y porqué
Bajo enforce la ACL por-subject confina a cada usuario del bus a la JetStream API de SUS rooms; ninguna identidad de usuario normal puede tocar los buckets KV_UNIBUS_*. La única identidad a la que el autenticador concede permisos plenos de JetStream es la identidad de servicio interna de membershipd (la que ya rompe el ciclo bootstrap del nonce/KV). Esa identidad era efímera por proceso, así que ningún proceso externo podía presentarla.
Solución elegida (la más simple y segura de las opciones del prompt — "creds del propio servicio" + "ejecución por loopback en un nodo"): el daemon persiste su identidad de servicio en --internal-id-file (cada nodo genera/carga la suya, 0600, junto a las claves TLS del nodo). La CLI user add --store kv, ejecutada por loopback en el nodo (el SAN del cert data-plane cubre 127.0.0.1), carga ese mismo archivo y presenta la nkey que el autenticador del nodo reconoce con permisos plenos → abre OpenJetStream y escribe el bucket KV_UNIBUS_users replicado.
Seguridad (no baja la posture): leer el archivo de identidad exige root en el nodo, lo que ya implica control total de ese nodo (la clave TLS del servidor y el cluster.pass ya están ahí). Co-ubicarlo no añade exposición práctica. La posture publicada en /healthz (enforce+acl+tls+cluster+store=kv) es idéntica con o sin el flag. Cambio respecto a 0011: la identidad interna pasa de efímera a durable (rotable borrando el archivo + reinicio); documentado.
Verificación
Secretos redactados: clave privada del operador, su
endpointderivado, el hex del operador (48bc…) y los de usuarios de prueba se muestran truncados. Nota de build: el worktree vive en/tmp, donde elreplace fn-registry => ../../../../dego.modno resuelve; se usó ungo.workfuera del árbol (GOWORK=/tmp/unibus_gaps.work, NO commiteado) solo para compilar. El checkout canónico bajo~/fn_registry/.../unibusno lo necesita.
Mecánica — vet + build + test (CGO_ENABLED=0)
$ CGO_ENABLED=0 go vet ./... -> rc=0
$ CGO_ENABLED=0 go build ./... -> rc=0
$ CGO_ENABLED=0 go test ./pkg/membership/ ./cmd/...
ok github.com/enmanuel/unibus/pkg/membership 8.455s
? github.com/enmanuel/unibus/cmd/chat [no test files]
? github.com/enmanuel/unibus/cmd/clientcheck [no test files]
ok github.com/enmanuel/unibus/cmd/membershipd 0.413s
? github.com/enmanuel/unibus/cmd/worker [no test files]
$ CGO_ENABLED=0 go test ./... (suite completa)
ok github.com/enmanuel/unibus/cmd/membershipd 0.370s
ok github.com/enmanuel/unibus/pkg/blobstore 0.091s
ok github.com/enmanuel/unibus/pkg/busauth 0.007s
ok github.com/enmanuel/unibus/pkg/client 6.168s
ok github.com/enmanuel/unibus/pkg/embeddednats 5.894s
ok github.com/enmanuel/unibus/pkg/frame 0.002s
ok github.com/enmanuel/unibus/pkg/membership 8.383s
Tests de integración GAP A (nodo enforce embebido, identidad de archivo):
$ go test ./cmd/membershipd/ -run TestUserAddStoreKV -count=1
ok github.com/enmanuel/unibus/cmd/membershipd 0.050s
TestUserAddStoreKV_GoldenAndIdempotent (escribe bajo enforce + ErrUserExists sin sobrescribir)
TestUserAddStoreKV_RequiresInternalIdentity (sin id file / id ausente -> error)
TestUserAddStoreKV_UnreachableKV (endpoint muerto -> error envuelto)
TestUserAddStoreKV_RemoteWithoutCARefused (remoto sin --ca -> rechazado)
GAP A — user add --store kv contra el cluster VIVO
Despliegue de verificación: binario nuevo + --internal-id-file a datardos (nodo no-crítico) con backups reversibles; arranca con identidad persistida, posture intacta, reincorporado a R3:
$ ssh dd ... (restart con binario nuevo)
internal service identity: persisted (/opt/unibus/secrets/internal.id) # 0600 root
healthz: {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
Golden (alta nueva, replicada R3):
$ ssh dd 'sudo /opt/unibus/membershipd user add --store kv --handle gapcheck_user --role member --sign-pub a58b…5622'
added user "gapcheck_user" (a58b…5622) role=member
KV_UNIBUS_users: leader=homer followers_current=2/2 msgs=2 <-- replicado a los 2 followers
Lectura replicada (user list --store kv en datardos lee el KV replicado):
HANDLE ROLE STATUS SIGN_PUB CREATED
gapcheck_user member active a58b…5622 2026-06-07T17:33:00Z
operator admin active 48bc…<op> 2026-06-07T16:48:12Z
Edge 1 — idempotencia (re-alta de la misma clave, y misma clave con otro rol):
$ ssh dd 'sudo .../membershipd user add --store kv --handle gapcheck_user --role member --sign-pub a58b…5622'
membershipd user add: user a58b…5622 already registered (unchanged); revoke it first to replace (exit 1)
$ ssh dd 'sudo .../membershipd user add --store kv --handle impostor --role admin --sign-pub a58b…5622'
membershipd user add: user a58b…5622 already registered (unchanged); revoke it first to replace (exit 1)
-> NO se sobrescribe ni se eleva a admin (sin escalado de privilegios vía re-alta).
Edge 2 — alta con un nodo caído (quórum 2/3). Se para homer (líder del stream); el stream re-elige líder a datardos y el alta commitea:
>>> STOP homer (líder de KV_UNIBUS_users) — quedan datardos+magnus = quórum 2/3
homer is-active=inactive
$ ssh dd 'sudo .../membershipd user add --store kv --handle gapcheck_user2 --role member --sign-pub e560…4bf4'
added user "gapcheck_user2" (e560…4bf4) role=member
KV_UNIBUS_users: leader=datardos followers_current=1/2 msgs=3 <-- líder failover homer->datardos; commit con quórum
>>> START homer (rejoin) -> is-active=active ; healthz 3/3 ; user list muestra los 3 usuarios
Error 1 — KV inalcanzable (puerto muerto):
$ ssh dd 'sudo .../membershipd user add --store kv --nats-url nats://127.0.0.1:4999 --handle x --sign-pub a58b…5622'
membershipd user add: --store kv: connect cluster NATS "nats://127.0.0.1:4999": nats: no servers available for connection (exit 1)
Limpieza (revoke de los 2 usuarios de prueba — revoke es flip de status, no hay hard-delete en KV):
$ ssh dd 'sudo .../membershipd user revoke --store kv a58b…5622' -> revoked user a58b…5622
$ ssh dd 'sudo .../membershipd user revoke --store kv e560…4bf4' -> revoked user e560…4bf4
$ user list --store kv:
gapcheck_user member revoked a58b…5622
gapcheck_user2 member revoked e560…4bf4
operator admin active 48bc…<op>
GAP B — verificación cliente END-TO-END real
Golden E2E (identidad operator de pass, CA por path, nkey+TLS+https a los 3 nodos, room E2E efímera, 5 msgs):
$ clientcheck --ca ca.crt --identity-file <operator.id 0600> \
--nats-seeds nats://magnus,homer,datardos:4250 --ctrl-seeds https://...:8470 --messages 5
connected: endpoint=<redacted> nats=nats://51.91.100.142:4250
created E2E room: id=01KTHHXA… subject=test.gapcheck.968e58c37a2d3d81 (encrypt=true sign=true persist=false)
published 5 messages; waiting for decrypted echoes...
GOLDEN OK: all 5 messages received and decrypted end-to-end
Edge E2E — failover con cliente conectado. Loop publish/subscribe 1/s mientras se para el nodo atado (datardos):
loop: publishing every 1s for 1m0s — stop a node now to test failover
t= 7s sent=7 recv=6 up=true node=nats://51.91.100.142:4250 publish=ok <-- atado a datardos
>>> 19:29:07 STOP datardos (nodo atado)
t= 8s sent=8 recv=7 up=true node=nats://135.125.201.30:4250 publish=ok <-- FAILOVER a magnus, sin perder mensaje
…
>>> 19:29:29 START datardos (rejoin)
…
loop done: sent=56 received=56 <-- CERO pérdida a través del corte
attached to nats://135.125.201.30:4250 for 49 ticks
attached to nats://51.91.100.142:4250 for 7 ticks
FAILOVER OBSERVED: client was attached to 2 distinct nodes across the run
LOOP OK: client kept receiving across the run (received=56)
Cluster sano 3/3 tras el corte (datardos reincorporado a R3).
GAP C — runbook corregido
deploy/cluster/README.md:
- Arranque (corregido): "arrancar magnus solo y verificar healthz" deadlockea — un nodo solo de un cluster de 3 no tiene quórum del meta-group RAFT y JetStream nunca queda current, así que
--store kvno crea los buckets y/healthzno devuelve ok hasta que se une un segundo nodo. Se documenta el arranque que forma quórum (los 3 cerca, el orden da igual) o escalonado apoyado en el retry loop de 120s; un nodo solo NUNCA se auto-sirve, así que no se debe condicionar el arranque del siguiente al healthz del anterior. Se nombran los 3 fixes de cold-start del 0011. - R1 inservible (corregido): a R1 los 6 buckets viven en un solo nodo (SPOF de autenticación); el cold start solo converge con los 3 fixes; ir directo a R3 una vez formado el cluster. R1 es artefacto transitorio, no hito.
- Vía de alta con cluster vivo: nueva sección documentando
user add --store kv(mecanismo, idempotencia, HA, sin hard-delete) que sustituye parar-sembrar-rearrancar. - Topología real: IPs reales,
ROUTE_NETWORK=public(no hay mesh WireGuard), magnus = host crítico convivido.
Gaps / pendientes
- Rollout del binario nuevo a magnus + homer: la verificación live de GAP A desplegó el binario
0.9.0+--internal-id-filesolo en datardos (nodo no-crítico). magnus y homer siguen con el binario 0011. La capability de alta está probada y disponible desde datardos; la escritura replica R3 a los otros dos. Estado mixto seguro a nivel de protocolo (cambios aditivos/node-local, posture idéntica). Para uniformidad + redundancia de la capability, rodar el binario a homer y magnus (comandos exactos en el README, con backups y healthz entre nodos). Se dejó como decisión del operador por tocar el host crítico magnus y por haber dos agentes vivos en el sub-repo; no es urgente. - Sin hard-delete de usuarios en KV:
revokedeja la fila en estadorevoked(denegada en ambos planos, auditable); el KV no borra filas, igual que el store SQLite. Los dosgapcheck_user*de prueba quedan revocados (inertes; sus claves privadas eran aleatorias y descartadas). No se inventó un delete a medias (regla del prompt). - Sin borrado de la room de prueba (GAP B): el control plane no expone borrado de rooms (no hay endpoint ni
DeleteRoomen el store). Las roomstest.gapcheck.<rand>creadas (efímeras, E2E, solo el operator como miembro, subject aleatorio) quedan como filas enKV_UNIBUS_rooms/members (inocuas, sin tráfico). Documentado; no se inventó un delete. - kill-2/3 (pérdida de quórum): fuera de scope (esperado fail-closed); el runbook lo deja como paso manual.
- Push a Gitea del 0011: sigue pendiente del operador (gap heredado del 0011, ajeno a este trabajo). Esta rama
quick/0011-deploy-gapsse pushea aoriginpara revisión; NO se mergea a master (lo revisa el operador).
Commits (rama quick/0011-deploy-gaps)
e1a7402 chore: bump unibus to 0.9.0 (live user-add + clientcheck)
ce72131 docs(cluster): correct runbook + wire --internal-id-file into deploy
3aa5a2c feat(clientcheck): end-to-end client verification (E2E room + failover)
02c2004 feat(membershipd): user add/list/revoke --store kv against a live cluster