- 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>
11 KiB
Report 0011 — Despliegue del cluster unibus de 3 nodos (magnus + homer + datardos)
- Fecha: 07/06/2026
- Autor: agente (Claude) + operador
- Ámbito:
projects/message_bus/apps/unibus/(sub-repodataforge/unibus) — despliegue del bus como cluster HA de 3 nodos sobre VPS OVH. Issue 0006g. - Estado: done — cluster en producción, R3 (HA real), posture enforce+ACL+TLS homogénea, chaos test de pérdida de 1 nodo superado.
Resumen
unibus está desplegado como un cluster de 3 nodos (magnus, homer, datardos) con
replicación R3 del plano de control (rooms/members/keys/users en JetStream KV + el
bucket de nonces anti-replay). La posture de seguridad es idéntica en los tres nodos
(enforce + per-subject ACL + TLS de datos + mutual-TLS de routes + --store kv), el
admin operador está sembrado en el KV replicado, y el cluster tolera la caída de
cualquier nodo (quórum 2/3). El material de deploy estaba preparado pero nunca se había
probado contra VPS reales; durante el despliegue se encontraron y corrigieron tres
defectos de arranque en frío del cluster que impedían la convergencia.
Topología real (Fase 0)
| Nodo | SSH | IP pública | Rol | Notas |
|---|---|---|---|---|
| magnus | magnus (root) |
135.125.201.30 | seed / nodo | = organic-machine.com = om = vps-3546abf9 |
| homer | homer (root) |
141.94.69.66 | nodo | vps-0db0572c |
| datardos | dd (root) |
51.91.100.142 | nodo | vps-ba7da64f |
Hallazgo crítico — identidad de magnus: el prompt describía magnus como un VPS con
coolify + minio + postgres + authentik + portainer + dagu. La realidad: magnus es el
mismo host que organic-machine.com / om (confirmado por DNS → 135.125.201.30, por la
nota del entry pass MAGNUS_ovh_ssh_-_ubuntu_organic-machine → ssh ubuntu@organic-machine.com,
y por el label "node":"magnus" del fleet-agent). Tras la reinstalación de om del
06/06/2026, coolify fue removido; magnus hoy corre caddy + grafana-fleet +
victoriametrics + docker(registry-api, gitea, gitea-postgresql) + fleet-agent — es el
host crítico del ecosistema (Gitea de todos los sub-repos + registry-api + hub de
monitorización). El bus se desplegó conviviendo con todo eso, sin tocar ningún
servicio existente.
Acceso SSH: los tres nodos exponen PermitRootLogin without-password. Se instaló la
clave pública del operador (id_ed25519) en /root/.ssh/authorized_keys de los tres vía
sudo (NOPASSWD disponible como ubuntu). Se creó el alias magnus en ~/.ssh/config.
Puertos del bus libres (regla dura): 8470 (HTTP), 4250 (NATS cliente), 6250 (routes) estaban LIBRES en los tres antes de tocar nada; cero colisión con caddy(80/443), gitea(3000/22222), registry-api(8420), VM(8428), grafana(3001).
Decisión WireGuard vs público
El runbook prefiere ROUTE_NETWORK=wg (routes server-to-server por mesh WireGuard privado).
Se verificó con wg show que NO existe mesh WireGuard entre los tres nodos del cluster:
homer y datardos ni siquiera tienen el binario wg instalado, y los únicos peers WG de
magnus(om) son los PCs personales del operador (home-wsl, windows-lucas), no los VPS.
La "datardos-wg 10.21.0.x" mencionada en el issue 0006 no está montada.
Decisión (autorizada por el runbook): ROUTE_NETWORK=public + mutual-TLS. Las routes
viajan por las IPs públicas pero están protegidas por la CA de routes separada (un cert
de cliente del plano de datos no puede presentarse en el puerto de routes). No se montó un
mesh WG a ciegas. Trade-off: el puerto 6250 escucha en la IP pública; en magnus (único nodo
con ufw activo) se restringió por ufw a las IPs de homer y datardos; en homer/datardos (ufw
inactivo, hosts Docker) el mutual-TLS de routes es la protección — activar un firewall desde
cero en hosts Docker en producción se evitó por riesgo de romper su networking y de lockout
SSH.
Cambios
Configuración de deploy (deploy/cluster/)
nodes.env: rellenado con la topología real (IP de magnus,ROUTE_NETWORK=public,KV_REPLICAS=3). Se limpió la sintaxis<PLACEHOLDER>/<SSH_HOST>de los comentarios, que el guardgrep -q '<[A-Z_]\+>'de los scripts interpretaba como placeholders sin rellenar y abortaba (defecto del material: los comentarios mismos disparaban el guard).- TLS:
generate-cluster-certs.shgeneró la CA de routes separada + cert de route y cert de datos por nodo (SANs con IP pública + hostname). Secreto de route:openssl rand -hex 32. - Secretos guardados en
pass:unibus/operator-identity(clave privada del operador),unibus/operator-sign-pub(hex),unibus/cluster-route-secret. Las claves TLS y elcluster.passquedan gitignored ensecrets/+out/, nunca a git.
Fixes de código (mergeados a master del sub-repo unibus, vía TBD --no-ff)
Tres defectos en la ruta de arranque en frío del cluster, ninguno visible en single-node (donde JetStream está listo al instante):
| # | Archivo | Defecto | Fix |
|---|---|---|---|
| 1 | pkg/embeddednats/embeddednats.go |
El pooling de routes de nats-server 2.10 (pool de 3 por defecto) generaba churn de "duplicate route"/"client closed" en el cluster pequeño, interrumpiendo los heartbeats RAFT del meta-group → re-elecciones perpetuas de líder. | Cluster.PoolSize = -1 (una route por par). |
| 2 | pkg/embeddednats/embeddednats.go |
Los nodos son hosts Docker; NATS anunciaba las IPs de los bridges Docker (172.x / 10.0.x) a los peers, que intentaban conectar a esas IPs privadas inalcanzables → desestabilizaba el meta-group. | Cluster.NoAdvertise = true (solo las routes explícitas a IPs públicas). Más un toggle UNIBUS_NATS_DEBUG (off por defecto) que habilita el logger y el puerto de monitoreo del nats embebido para depurar. |
| 3 | pkg/membership/jetstream_store.go |
Una op de KV es request/reply de NATS; en un cluster frío la op se publicaba una vez, antes de que el nodo tuviera contacto con el meta-leader, así que el request se descartaba (no se encola) y la única llamada con context largo se bloqueaba hasta el timeout. | Reintentar cada bucket con contexts cortos hasta éxito o agotar un budget de bootstrap (120s); aterriza en cuanto el meta-group converge. |
Con los tres, el cluster forma limpio, crea los buckets KV, escala R1→R3 in-place y sobrevive la pérdida de un nodo.
Verificación (evidencia ejecutable)
Fase 4 — posture homogénea + cluster formado
$ for h in magnus homer dd; do curl -fsS https://127.0.0.1:8470/healthz --cacert ...; done
[magnus] {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
[homer] {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
[dd] {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
/jsz: meta-leader=datardos, cluster_size=3, 6 streams creados, NRestarts=0 en los 3.
Fase 5 — admin sembrado en el KV replicado
$ membershipd user add --db seed.db --handle operator --sign-pub 48bc...73fa --role admin
$ membershipd migrate-to-kv --replicas 1
migrated to KV: 0 rooms, 0 members, 0 keys, 1 users
$ /jsz KV_UNIBUS_users -> msgs=1 (admin presente en el cluster)
(El seed se hace por el bootstrap loopback no-auth del runbook, antes de arrancar el cluster; con los fixes y arranque escalonado magnus→homer→datardos, el cluster adopta el store seedeado y converge.)
Fase 6 — escalado R1 → R3 in-place (HA real)
Antes: cada bucket leader=magnus, 0 followers (R1, SPOF en magnus)
$ KV_REPLICAS=3 en los 3 cluster.env; restart rolling (homer primero)
Después: cada uno de los 6 buckets leader=magnus, followers_current=2/2 (R3, quórum 2/3)
Fase 7 — chaos test (pérdida de 1 nodo)
$ systemctl stop membershipd-cluster (magnus, líder de los 6 streams) -> magnus DOWN
[homer] healthz ok [dd] healthz ok
meta-leader re-electo: homer (era datardos), meta_size=3
los 6 streams re-eligieron líder a homer automáticamente, sirviendo con quórum 2/3
KV operativo degradado: KV_UNIBUS_users accesible, admin msgs=1
$ systemctl start membershipd-cluster (magnus) -> rejoin
los 6 streams: followers_current=2/2 (magnus re-sincronizó como follower R3)
No se mataron 2 nodos (sería pérdida de quórum esperada, fuera de scope).
Sanity — servicios existentes de magnus intactos
caddy/grafana-fleet/victoriametrics/fleet-agent = active
docker: registry-api, gitea, gitea-postgresql corriendo; gitea HTTP=200
puertos 80/443/3000/3001/8420/8428 (existentes) + 4250/6250/8470 (bus) coexisten
Producción limpia
UNIBUS_NATS_DEBUGretirado de los 3cluster.env; rolling restart; puerto de monitoreo 8222 cerrado; los 3 healthz OK.- ufw de magnus: añadidas (aditivas, sin tocar reglas existentes)
8470/tcp,4250/tcppúblicas y6250/tcprestringido a homer+datardos. go vet+go test ./pkg/embeddednats/ ./pkg/membership/→ ambosok.
Gaps / pendientes
- Push a Gitea pendiente: los 2 commits de fix están en
masterlocal del sub-repounibus, no pusheados. El operador debegit push(o/full-git-push). - Seed del admin en cluster corriendo (GAP del report 0009): no existe
user add --store kv; añadir usuarios requiere el bootstrap loopback con el cluster parado. Para sembrar el admin hubo que limpiar stores, seedear en magnus standalone y arrancar el cluster (orden escalonado magnus→homer→datardos). Mientras no exista una vía de alta de usuarios al KV con el cluster vivo, cada alta requiere ese procedimiento. Recomendación: implementaruser add --store kvque use la conexión interna privilegiada. - Verificación de cliente end-to-end no ejecutada: el chaos test validó el plano de
control (healthz + failover de meta/stream leaders + KV legible con 2/3), pero no se conectó
un cliente del bus autenticado (nkey+TLS) a crear/publicar en una room durante el corte.
Queda como verificación complementaria con la identidad
operator(enpass). - R1 fue inservible en este cluster: el rollout R1→R3 del runbook asume que R1 funciona primero; en la práctica R1 dejaba los 6 buckets en un único nodo (SPOF) y, sobre todo, el arranque solo convergió tras los 3 fixes. El despliegue saltó directo a R3 una vez formado.
- Orden de arranque del runbook: el runbook indica arrancar "magnus solo y verificar
healthz" antes de los demás; con
--cluster-namede 3 un nodo solo no tiene quórum del meta-group, así que magnus solo nunca sirve healthz hasta que se une un segundo nodo. El arranque correcto es simultáneo (cluster limpio) o escalonado con el retry loop que tolera la espera de quórum. Conviene corregir esa instrucción del README.
Gaps cerrados en report 0012 (0012-2026-06-07-unibus-deploy-gaps-closed.md): GAP A
user add --store kv (alta al KV con el cluster vivo), GAP B verificación cliente end-to-end
real + failover (cmd/clientcheck), GAP C runbook corregido. Verificado contra los 3 VPS.