Files
message_bus/reports/0012-2026-06-07-unibus-deploy-gaps-closed.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

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-repo dataforge/unibus) — cierre de los gaps que el despliegue del cluster de 3 nodos (report 0011) dejó abiertos. Rama quick/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 endpoint derivado, el hex del operador (48bc…) y los de usuarios de prueba se muestran truncados. Nota de build: el worktree vive en /tmp, donde el replace fn-registry => ../../../../ de go.mod no resuelve; se usó un go.work fuera del árbol (GOWORK=/tmp/unibus_gaps.work, NO commiteado) solo para compilar. El checkout canónico bajo ~/fn_registry/.../unibus no 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 kv no crea los buckets y /healthz no 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-file solo 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: revoke deja la fila en estado revoked (denegada en ambos planos, auditable); el KV no borra filas, igual que el store SQLite. Los dos gapcheck_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 DeleteRoom en el store). Las rooms test.gapcheck.<rand> creadas (efímeras, E2E, solo el operator como miembro, subject aleatorio) quedan como filas en KV_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-gaps se pushea a origin para 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