diff --git a/reports/0001-2026-06-07-unibus-grafana-monitoring.md b/reports/0001-2026-06-07-unibus-grafana-monitoring.md new file mode 100644 index 0000000..b485de2 --- /dev/null +++ b/reports/0001-2026-06-07-unibus-grafana-monitoring.md @@ -0,0 +1,145 @@ +# Report 0001 — Monitorización de unibus en Grafana/VictoriaMetrics + +- **Fecha:** 07/06/2026 +- **Autor:** agente (Claude Opus 4.8) +- **Ámbito:** `projects/fleet_monitoring/` (exporter + dashboard + deploy) + 1 función nueva en el registry (`functions/infra/`). Solo lectura de `projects/message_bus/apps/unibus/` (endpoint `/healthz` + CA). +- **Estado:** done (métricas de `/healthz`); gap declarado en métricas profundas NATS/JetStream. + +## Resumen + +Se añadió monitorización del cluster de mensajería **unibus** (NATS+JetStream, 3 nodos: magnus, +homer, datardos) a la stack de Grafana/VictoriaMetrics que ya corre en magnus, **sin instrumentar +el bus**. Un nuevo daemon `unibus_exporter` sondea el endpoint público de salud de cada nodo +(`GET https://:8470/healthz`, TLS verificado con la CA del cluster) en un bucle de 15 s, +traduce la respuesta a métricas Prometheus y las empuja a la VictoriaMetrics local de magnus. +Un dashboard dedicado las visualiza. No se tocó nada del código de unibus ni la stack existente +(Caddy/Gitea/VM/Grafana/fleet-agent intactos). + +## Qué métricas se recolectan y cómo + +El `/healthz` de cada nodo devuelve, verificado en producción: + +```json +{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"} +``` + +De ahí se derivan estas series (labels `node` e `instance` = nombre del nodo, label común +`job=unibus_exporter`): + +| Serie | Origen | Significado | +|---|---|---| +| `unibus_up` | exporter | 1 si el nodo respondió `/healthz`, 0 si el GET/parseo falló | +| `unibus_status_ok` | healthz `status` | 1 si `status=="ok"` | +| `unibus_posture_enforce` | healthz `posture.enforce` | enforcement de auth (1/0) | +| `unibus_posture_acl` | healthz `posture.acl` | ACL de subjects (1/0) | +| `unibus_posture_tls` | healthz `posture.tls` | TLS del transporte (1/0) | +| `unibus_posture_cluster` | healthz `posture.cluster` | modo cluster activo (1/0) | +| `unibus_store_kv` | healthz `posture.store` | 1 si el store es `kv` (JetStream KV) | +| `unibus_scrape_error` | exporter | 1 si el scrape de ese nodo falló | +| `unibus_scrape_duration_seconds` | exporter | latencia del GET `/healthz` | +| `unibus_cluster_size` | exporter (config) | nº de nodos configurados (los vivos = `sum(unibus_up)`) | + +`unibus_up=0` lo emite el exporter (no el parser) cuando el GET falla, para que un nodo caído sea +**visible** en Grafana, no simplemente ausente. + +## Componentes entregados + +### Función del registry — `parse_unibus_health_go_infra` +- `functions/infra/parse_unibus_health.go` + `.md` + `_test.go` (grupo `fleet-metrics`, tags `unibus`). +- `func ParseUnibusHealth(node string, body []byte) ([]PromSample, error)` — pura de transformación + (clasificada `impure` solo por el error de unmarshal). Tests golden/edge/error. +- **Nota de proceso:** el prompt pedía delegar esta función a `fn-constructor`, pero ese + subagent_type no existe en este entorno. Tras confirmar con el usuario ("crear en el registry"), + se creó a mano siguiendo el flujo (archivos + tests + `fn index`). + +### App — `unibus_exporter` (sub-repo Gitea propio) +- `projects/fleet_monitoring/apps/unibus_exporter/`: `main.go`, `config.go`, `unibus.example.json`, + `systemd/unibus-exporter.service`, `app.md`, `.gitignore`. +- Compone `parse_unibus_health` + `format_prom_exposition` + `push_prom_remote` del registry (no + reescribe push ni formato). Config JSON; secretos (CA, basic-auth) fuera de argv. Verifica TLS + siempre contra la CA del cluster (sin `InsecureSkipVerify`). +- `git init -b master` + commit inicial hecho (apps/* está gitignored en el project; sin sub-repo + el código se perdería). Falta crear el repo Gitea remoto: lo hará `/full-git-push`. + +### Dashboard — `unibus-cluster.json` +- `projects/fleet_monitoring/hub/dashboards/unibus-cluster.json` (formato de los `fleet-*.json`, + datasource `victoriametrics`, carpeta **Fleet**, uid `unibus-cluster`, 9 paneles): nodos up, + cluster size, nodos caídos, posture homogénea segura, up/down por nodo, matriz de posture por + nodo (state-timeline enforce/acl/tls/cluster/store-kv × 3 nodos), latencia de scrape y tabla de + estado por nodo. Panel "Meta-leader" preparado (muestra n/d sin métricas NATS). + +### Deploy — `deploy_unibus_exporter.sh` +- `projects/fleet_monitoring/hub/deploy_unibus_exporter.sh`: compila el binario linux/amd64, sube + binario + CA del cluster a magnus (`/opt/unibus-exporter`, `/etc/unibus-exporter/ca.crt` chmod + 600 la config) e instala el servicio systemd apuntando a `http://127.0.0.1:8428/...` (VM local, + sin auth porque corre en el propio hub). + +## Verificación (evidencia ejecutable) + +**1. Acceso y healthz de los 3 nodos (CA del cluster por path):** +``` +$ curl -s --cacert .../deploy/tls/ca.crt https://135.125.201.30:8470/healthz +{"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"} + (idéntico en homer 141.94.69.66 y datardos 51.91.100.142) +``` + +**2. Tests de la función:** +``` +$ go test -tags fts5 -run ParseUnibusHealth ./functions/infra/ +ok fn-registry/functions/infra 0.004s +$ ./fn index # → "Indexed 1450 functions ..."; ./fn show parse_unibus_health_go_infra → OK +``` + +**3. Exporter build + scrape/push único de prueba (local → VM):** +``` +$ ./unibus_exporter -config -once +unibus_exporter starting: nodes=3 hub="https://metrics-…/api/v1/import/prometheus" interval=15s +pushed 28 samples for 3 nodes # 1 cluster_size + 3 nodos × 9 series +``` + +**4. Daemon systemd en magnus:** +``` +$ systemctl is-active unibus-exporter → active +$ systemctl is-enabled unibus-exporter → enabled +$ journalctl -u unibus-exporter → "pushed 28 samples for 3 nodes" +``` + +**5. Series en VictoriaMetrics (magnus, 127.0.0.1:8428):** +``` +sum(unibus_up) = 3 +unibus_cluster_size = 3 +count(unibus_up==1) = 3 +unibus_posture_enforce: magnus=1 homer=1 datardos=1 (job=unibus_exporter) +unibus_store_kv: magnus=1 homer=1 datardos=1 +unibus_scrape_duration_seconds: magnus≈4ms homer≈32ms datardos≈19ms +``` + +**6. Dashboard en Grafana (visto en el navegador):** +- `https://grafana-…/d/unibus-cluster` — carpeta Fleet, 9 paneles renderizando datos reales. +- Nodos up: **3** · Cluster size: **3** · Nodos caídos: **0** · Posture homogénea segura: + **OK (enforce+acl+tls+cluster+kv)** · matriz de posture: 15 celdas en verde · tabla de estado + por nodo con ✓ en up/enforce/acl/tls. +- Captura: `unibus-grafana-monitoring-dashboard.png` (junto a este report). +- API: `GET /api/dashboards/uid/unibus-cluster` → `dashboard CARGADO: unibus — Cluster | folder: Fleet | paneles: 9`. + +## Gaps / pendientes + +- **Métricas profundas NATS/JetStream (msgs/s, conexiones, KV bucket msgs, RAFT leader por + stream, `NRestarts`) — NO incluidas, gap recomendado.** La vía es el monitoring embebido de + NATS (puerto 8222), que se confirmó **cerrado** en los 3 nodos en producción + (`curl 127.0.0.1:8222/varz` → CLOSED en magnus/homer/dd). Activarlo (bindeado a 127.0.0.1 + + scrape local, o sacar `/jsz` por SSH) exige tocar la config/unit de los nodos del cluster, que + además están siendo trabajados por otros agentes ahora mismo. Se decidió **no forzarlo** por + riesgo en producción. Para abordarlo después: añadir `UNIBUS_NATS_DEBUG`/equivalente bindeado a + loopback en cada nodo (cambio aditivo, coordinado con `unibus/deploy/cluster/`), un scrape local + del `/jsz`+`/varz` y nuevas series `unibus_jsz_*`. El panel "Meta-leader" del dashboard ya está + preparado para cuando exista `unibus_meta_leader`. +- **`unibus_cluster_size`** refleja el nº de nodos **configurados** en el exporter (3), no un + recuento que el bus reporte (healthz no lo expone). Los nodos vivos se ven con `sum(unibus_up)`. +- **Commit en el repo padre `fn_registry` sin pushear (a propósito):** la función nueva quedó + commiteada en local (`82f1f1bd`, master ahead 1) pero **no se hizo push** del padre, para + respetar el aislamiento pedido. El humano debería revisarlo y pushearlo (junto con `fn index` + para regenerar `registry.db`, que está gitignored). +- **Repo Gitea del exporter:** `apps/unibus_exporter` tiene su `git init` + commit local pero aún + no tiene remoto en Gitea; `/full-git-push` lo creará (`dataforge/unibus_exporter`). +- **Vida útil (DoD capa 3):** validado funcionalmente hoy; falta la ventana de uso real ≥7 días. diff --git a/reports/0008-2026-06-07-unibus-admin-users-wired.md b/reports/0008-2026-06-07-unibus-admin-users-wired.md new file mode 100644 index 0000000..e6db93d --- /dev/null +++ b/reports/0008-2026-06-07-unibus-admin-users-wired.md @@ -0,0 +1,124 @@ +# Report 0008 — unibus_admin: pestaña Users cableada a la API del plano de control + +- **Fecha:** 07/06/2026 +- **Autor:** agente (Claude Opus 4.8) +- **Ámbito:** `projects/message_bus/apps/unibus_admin` (gateway Go + SPA Mantine), sub-repo `dataforge/unibus_admin`, rama `master` +- **Estado:** done (código + verificación E2E del código) — con un gap de **despliegue** del bus documentado abajo + +## Resumen + +La pestaña Users del panel de administración de unibus estaba degradada en cluster +porque el gateway sólo sabía gestionar la allowlist abriendo el `membership.Store` +directo con `--db`, y caía a `users_backend=none` (estado informativo) cuando no había +acceso directo al store. El control plane de unibus ahora expone una API HTTP admin-only +de users, y `pkg/client` la envuelve con `ListUsers/AddUser/RevokeUser` firmados. Este +trabajo cablea el gateway a esos métodos (camino principal: API HTTP firmada, funciona +en cluster), deja la pestaña Users operativa (sin el estado "degradado"), y verifica la +cadena completa list → add → revoke (con idempotencia 409) end-to-end contra un +`membershipd` master real. `--db` queda como fallback single-node explícito. + +## Cambios + +| Archivo | Cambio | +|---|---| +| `internal/admin/repo.go` | Eliminado `ErrUsersUnavailable` y el camino "none"; comentarios del interface `Repo` y de `MeInfo.UsersBackend` actualizados (`"control-plane"` \| `"sqlite"`). | +| `internal/admin/repo_bus.go` | `ListUsers/AddUser/RevokeUser` usan `r.cli.*` (API firmada) cuando no hay store directo; con `--db` siguen usando el store. `UsersWritable()` → `true` (el gateway conectado siempre puede; el control real es `requireAdmin` en runtime). Backend por defecto `"control-plane"`, `"sqlite"` con `--db`. | +| `internal/admin/server.go` | Handlers `/api/users` simplificados (cualquier error del control plane → 502 con su mensaje, que ya trae el código embebido, p.ej. `(HTTP 409)`); quitado el manejo de `ErrUsersUnavailable` y el import `errors`. | +| `main.go` | `--db` documentado como opcional (fallback single-node); por defecto el backend es `control-plane`. Logs de arranque acordes. | +| `web/src/pages/UsersPage.tsx` | Quitado el `Alert` "Gestión de users no disponible" y el gating `writable` (botón Add deshabilitado / revoke oculto). El badge muestra `backend: `. La tabla (handle/rol/estado/sign_pub/creado), el modal Add (handle + sign-pub hex64 + rol) y revoke con confirmación se mantienen. | +| `web/dist/*` | Bundle SPA recompilado (embebido por el binario). | +| `app.md` | Diagrama, tabla de capacidades, ejemplos de arranque y gaps actualizados al cableado control-plane + el gap de despliegue del bus. | + +El gateway **reutiliza** los métodos del cliente del bus (`pkg/client`), no reimplementa +firma ni HTTP. No se tocó el repo `unibus` (sólo se importa por `replace => ../unibus`). + +## Verificación + +### Mecánica (verde) + +``` +go vet ./... → VET_EXIT:0 +go build -o unibus_admin . → BUILD_EXIT:0 (19 MB, embebe la SPA) +cd web && pnpm build → tsc -b + vite build OK (sin errores de tipo) +``` + +### Cobertura E2E del código (verde) — contra `membershipd` master real + +El cluster de producción aún no expone la ruta `/users` (ver Gaps), así que la cadena +del panel se verificó contra un `membershipd` compilado de master (con la ruta) arrancado +localmente (sqlite, `--bus-auth soft`), con el operador sembrado como admin +(`membershipd user add --role admin`). El gateway firma como el operador; el bus verifica +con `requireAdmin`. Secuencia ejecutada **por la API del panel** (`/api/users`): + +| Paso | Comando (gateway en :18481) | Resultado | +|---|---|---| +| list (antes) | `GET /api/users` | `[operator(admin,active)]` | +| add | `POST /api/users {gapcheck_admin_ui, member}` | `201 {"status":"added"}` | +| list | `GET /api/users` | `gapcheck_admin_ui` **active/member** + operator | +| re-alta (idempotencia) | `POST /api/users` (mismo sign_pub) | `409` `user already registered (unchanged); revoke it first` | +| revoke | `POST /api/users/revoke {sign_pub}` | `200 {"status":"revoked"}` | +| list (final) | `GET /api/users` | `gapcheck_admin_ui` **revoked** + `revoked_at`; operator intacto | + +`sign_pub` de prueba: `1cb658d9d3f23e6bf5791a3865a1eec731f3df30ad4b85f6fca0074b8bdc98a4` +(aleatorio, role member). Limpieza: revocado al final (revoke es la limpieza; no hay hard +delete). El operador admin y la DB de prueba vivían en `/tmp` efímero, ya borrados. **No se +tocó la allowlist del cluster de producción.** + +### SPA (verde) — verificada visualmente (browser MCP) + +Panel servido en `:18481`, pestaña Users: +- Badge `backend: control-plane` (sin Alert degradado), botón "Añadir user" **activo**. +- Tabla con `gapcheck_admin_ui` MEMBER/REVOKED (sin botón revoke por estar revocado) y + `operator` ADMIN/ACTIVE (con botón revoke). +- Modal "Añadir user al bus" renderiza Handle + `sign_pub (hex, 64)` + Rol(member) + + Cancelar/Añadir. + +### Conectividad contra el cluster vivo (verde, salvo la ruta ausente) + +- Gateway local apuntado a homer (`141.94.69.66:8470`) + datardos (`51.91.100.142:8470`), + TLS+nkey con la CA del bus, identidad operator desde `pass`: **conecta y firma** + (`/api/me` devuelve el endpoint real del operador `vI8HXcintzK-…`). `GET /api/users` + → `404 page not found` desde el control plane (ruta ausente en esos binarios). +- Panel efímero arrancado **en magnus** (puerto 18482, sin tocar el `unibus-admin.service`) + contra el control plane loopback de magnus (KV, enforce, TLS): `/api/me` OK (pasa el + `enforce` de magnus firmando como operador), `GET /api/users` → `404`. El binario + efímero y su log se borraron; el service real quedó `active`. + +## Estado del deploy en magnus + +**No se re-desplegó el service.** El panel `unibus-admin.service` sigue `active` en magnus +(healthz 200). Build linux/amd64 del binario nuevo: **listo** (probado en magnus en puerto +efímero). El re-deploy queda **bloqueado por una dependencia**: re-desplegar ahora cambiaría +la pestaña Users de un Alert informativo a un error `502 (GET /users → 404)`, porque el +`membershipd` de magnus aún no expone la ruta. Procedimiento para cuando el bus esté en +v0.10.0 (aditivo, sin tocar otros sites del Caddyfile): + +```bash +cd web && pnpm install && pnpm build && cd .. +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o unibus_admin . +scp unibus_admin magnus:/opt/unibus_admin/unibus_admin +ssh magnus 'systemctl restart unibus-admin.service' # el unit ya arranca sin --db (backend control-plane) +ssh magnus 'curl -fsS http://127.0.0.1:8480/healthz' # + comprobar la pestaña Users vía el subdominio Caddy +``` + +El `deploy/unibus-admin.service` no necesita cambios para el camino control-plane (no usa +`--db`). Verificar tras el restart que `ExecStart` no incluye `--db` (si lo incluyera, +quitarlo para usar la API del plano de control). + +## Gaps / pendientes + +1. **Cluster sin la ruta `/users` (despliegue).** Los `membershipd` desplegados + (magnus/homer/datardos) son anteriores al merge de la API de users y devuelven 404. La + verificación E2E del panel contra el cluster vivo **de producción** no es posible hasta + actualizar el bus a v0.10.0. La cadena está verificada contra un `membershipd` master + real (mismo código que correrá en el cluster), y el gateway ya conecta y firma bien + contra los nodos reales. Acción fuera de este aislamiento (toca el repo `unibus`/su + deploy): actualizar los binarios del cluster. +2. **Mapeo de código de estado.** Un `409` (re-alta idempotente) del control plane llega a + la SPA como `502` con el mensaje del bus (que incluye `(HTTP 409)`). La SPA muestra el + mensaje íntegro, así que la UX es correcta y accionable; no se parseó el string de error + para propagar el código exacto (se evitó un parser frágil — KISS). Mejora menor opcional + si `pkg/client` expone el status estructuradamente. +3. **Replicación `followers 2/2`.** No se inspeccionó la replicación KV cross-node porque la + ruta `/users` no está viva en el cluster; el panel no expone métricas de JetStream + (varz/jsz) — gap ya conocido de la pestaña Cluster. diff --git a/reports/0008-2026-06-07-unibus-decentralization-audit.md b/reports/0008-2026-06-07-unibus-decentralization-audit.md new file mode 100644 index 0000000..ce0e86d --- /dev/null +++ b/reports/0008-2026-06-07-unibus-decentralization-audit.md @@ -0,0 +1,125 @@ +# Report 0008 — unibus: auditoría de seguridad de la superficie descentralizada (issue 0003, vectores N1–N6) + +- **Fecha:** 07/06/2026 +- **Autor:** agente auditor (Claude Opus 4.8), mentalidad red-team +- **Ámbito:** `projects/message_bus/apps/unibus` (sub-repo `dataforge/unibus`), HEAD `df3b62a` (v0.7.0, master). Paquetes `embeddednats`, `busauth`, `membership`, `client`, `cmd/membershipd`. Superficie nueva introducida por la descentralización (issue 0003, fases 0003a–0003e). +- **Estado:** done — auditoría + verificación activa con cluster efímero en proceso. NO se modificó código de producción; los tests de ataque fueron efímeros (creados, ejecutados, borrados); el working tree quedó idéntico al baseline. +- **Origen:** la re-auditoría previa (report 0006-security-reaudit) auditó el commit PRE-0003 (618f6b6) porque durante 0003 el working tree no compilaba. La superficie distribuida de 0003 nunca se había red-teameado. Esta auditoría la cubre antes de exponer el bus público en cluster (0003f). + +--- + +## Resumen ejecutivo — ¿es seguro exponer el bus DESCENTRALIZADO (cluster) público HOY? + +**Veredicto: NO** para el bus descentralizado en cluster, tal como el binario lo cablea en v0.7.0. Dos hallazgos bloqueantes, ambos demostrados con ataque ejecutable sobre un cluster efímero en proceso: + +1. **N3 — anti-replay roto en cualquier cluster real.** El binario `membershipd` **nunca llama** `Server.UseReplicatedNonces`. Cada nodo mantiene su `memNonceCache` por-proceso. Una petición firmada una vez y aceptada en el nodo A se **acepta también replayada al nodo B** (demostrado: 200 en A, 200 en B). El cierre de replay multi-nodo que el issue reivindica existe como API (`kvNonceStore`) y como test (`TestReplicatedNonceRejectsCrossNodeReplay`, que lo invoca explícitamente), pero el binario que se desplegaría no lo usa. + +2. **N2 — fuga total del control plane vía `$JS.API.>` al activar el KV.** El grant ACL de cliente es `clientInfraSubjects = {"_INBOX.>", "$JS.API.>"}`. En cuanto el control plane descentralizado esté activo (los buckets `UNIBUS_users/rooms/members/room_keys` en JetStream KV — el objetivo central de 0003), `$JS.API.>` permite a **cualquier peer registrado** leer esos buckets directamente por NATS, **bypassando toda la autorización HTTP**. Demostrado: una identidad registrada, rol `member`, miembro de ninguna room, cosechó el allowlist completo (handles, roles, claves), el grafo de rooms (subjects, owners) y la metadata de sealed-keys de una room ajena. + +**Matiz honesto (importante para priorizar):** el binario **v0.7.0 todavía no activa el control plane descentralizado**. El store es **siempre SQLite** (`membership.Open` en `main.go:90`); el flag `decentralized` existe en `dev/feature_flags.json` pero **ningún código Go lo lee**; `OpenJetStream` solo lo consume el comando `migrate-to-kv`. En consecuencia: + +- Como **nodo único standalone** (`enforce` + TLS), unibus sigue tan seguro como tras el hardening 2 (report 0007): toda la regresión verde, `govulncheck` 0 alcanzables. **Eso sí es desplegable.** +- Como **cluster multi-nodo** (lo que 0003 persigue), **NO es seguro**: N3 es explotable de inmediato (cualquier cluster con el binario actual), y N2 se vuelve explotable en cuanto se complete el wiring del KV **o** simplemente se ejecute `migrate-to-kv` contra un nodo que sirva clientes bajo ACL (ese comando SÍ está cableado y crea+puebla los buckets). + +Como la pregunta es específicamente sobre el bus **descentralizado en cluster**, la respuesta es **NO — corregir N3 y N2 antes de 0003f.** + +--- + +## Hallazgos NUEVOS de la superficie 0003 + +| Sev | Vector | Descripción | Evidencia (comando + salida) | Fix | +|---|---|---|---|---| +| **ALTA** | N3 | El binario `membershipd` no llama `UseReplicatedNonces` ni siquiera con `--cluster-name`. Cada nodo usa `memNonceCache` por-proceso → un request firmado capturado se replaya con éxito a otro nodo del cluster (su cache nunca vio el nonce). El anti-replay multi-nodo es **nulo** en el binario desplegable. | Ataque efímero `TestAttack0008_N3` (2 `NewServer(...,AuthEnforce)` = wiring exacto del binario, sin `UseReplicatedNonces`): `node A first use -> 200 ; SAME ts+nonce replayed to node B -> 200`. | Cablear `UseReplicatedNonces(js, replicas)` en `main.go` cuando hay JetStream/cluster; fail-fast si el bucket no se crea. Idealmente: `--cluster-name != ""` ⇒ nonce replicado obligatorio. | +| **ALTA** | N2 | El grant ACL `$JS.API.>` (en `acl.go:20`, `clientInfraSubjects`) deja a cualquier peer registrado ejecutar la JetStream API y **leer los buckets KV del control plane** (`KV_UNIBUS_users`, `KV_UNIBUS_rooms`, `KV_UNIBUS_members`, `KV_UNIBUS_room_keys`) por NATS, saltándose `requireMember` y los chequeos own-endpoint del HTTP. Fuga del allowlist (handles+roles+claves), del grafo de rooms (subjects/owners) y de la metadata de sealed-keys (endpoint destino + existencia). El propio `TestReaudit_H4_WildcardMetadataLeak` documenta este residual y dice que se difirió a "la línea 0003" — pero **0003 no lo cerró**; con el KV escala de "fuga de metadata de subject" a "fuga del control plane entero". | Ataque efímero `TestAttack0008_N2` (poblar KV sin auth, rebootear el mismo store con la ACL de producción, eve `member` lee): `eve read UNIBUS_users[ceo] = handle="ceo-root-admin" role="admin" status="active"`; `eve read UNIBUS_rooms[PRIVROOM] subject="room.board.ma-deal" owner=... encrypt=true`; `eve read UNIBUS_room_keys[PRIVROOM..1]`. | NO conceder `$JS.API.>` entero. Derivar permisos JetStream mínimos por-room (API del stream/consumer de las rooms del peer) y **denegar** los streams `KV_UNIBUS_*`. Mejor aún: aislar el control plane KV en una NATS *account* separada inaccesible a clientes. Mientras tanto, **no** activar `decentralized: on` ni `migrate-to-kv` contra un nodo que sirva clientes con ACL. | +| **MEDIA** | N1 | **El cluster es tan seguro como su nodo más débil.** El data plane reenvía todos los subjects entre nodos; un nodo del cluster sin authenticator (o `--bus-auth off`) permite a un peer **no autenticado** `Subscribe(">")` y cosechar el tráfico reenviado de los nodos con ACL. Mitigado para el binario embedded en bind público por `validateBootConfig` (un bind no-loopback exige `enforce`), pero explotable si se usa NATS externo, un bind loopback que rutea, o un nodo mal configurado. | Ataque efímero `TestAttack0008_N1` escenario 2 (cluster 2 nodos ACL + 1 nodo `withACL=false`): `unauthenticated mallory on a no-auth cluster node harvested room-A traffic forwarded from the ACL'd node: "secret-A-2"`. | Forzar/documentar posture homogénea (enforce+ACL+TLS) en TODOS los nodos. Health/arranque que rechace formar cluster con un peer en posture inferior. Nunca exponer el puerto de cliente de un nodo sin enforce. | +| **MEDIA** | N4 | La ACL congela permisos al conectar; un peer que crea/se une a una room debe llamar `client.RefreshSession()` para poder pub/sub en su subject. **Ningún cliente del repo lo llama** (`cmd/chat`, `cmd/worker`, `mobile`, `gateway`): grep solo halla la definición y comentarios. El fallo es *fail-closed* (deniega, no abre), pero rompe la usabilidad bajo `enforce`+ACL, lo que empuja al operador a **desactivar la ACL** (volver a `NewNkeyAuthenticator` abierto) para que las apps funcionen → regresión de seguridad a discreción del operador. | `grep -rn RefreshSession --include="*.go" \| grep -v _test.go` → solo definición + comentarios; `cmd/worker/main.go` crea `room.ModeNATS` y publica sin refrescar. | Llamar `RefreshSession` tras cambios de membresía en todos los clientes, o implementar refresh transparente (rehacer suscripciones). Documentar el contrato como requisito de despliegue. | +| **MEDIA** | N3 | El bucket de nonces en **R1** es un SPOF de autenticación: si cae el nodo dueño del stream `KV_UNIBUS_nonces`, todo `Create` falla → el `kvNonceStore` (correctamente) hace *fail-closed* → **todos los requests autenticados se rechazan** (DoS de auth de todo el bus). Inherente a R1; se mitiga con R3 (quorum 2/3). | Análisis de `nonce_kv.go:67-77` (fail-closed) + `JetStreamConfig.Replicas` configurable; el rollout R1 del issue lo deja expuesto. | No vender R1 como "HA". Documentar que el control plane (incl. nonces) no tolera caída hasta R3. Considerar degradación controlada vs. DoS total. | +| **BAJA** | N1 | La CA de routes es **la misma** que la del data plane de clientes (`RouteTLSConfig` reusa la CA del 0001). Acopla dos fronteras de confianza: si algún día se emiten certs de cliente con esa CA, podrían presentarse al puerto de routes (el segundo factor sigue siendo el password de cluster). Además el password de cluster viaja en `--routes nats://user:pass@host` → visible en `ps`/`/proc//cmdline`/journald. | Lectura de `tls.go:55-75` (misma `pool` para RootCAs+ClientCAs) y `main.go:59` (password en argv). | CA separada para routes (o restricción por EKU/nombre). Pasar el secreto de cluster por archivo/env, no por argv. | +| **BAJA** | N6 | `migrate-to-kv` es idempotente (Put-overwrite) y hace backup (`VACUUM INTO`) — correcto. Pero **ejecutarlo contra un nodo enforce+ACL crea y puebla los buckets KV**, que quedan legibles por `$JS.API.>` (ver N2) aunque el control plane siga leyendo de SQLite. Y si `--nats-url` apunta a un nodo remoto sin `--ca`, el allowlist (handles/roles/sign pubs) viaja **plaintext** por NATS (los sealed-keys ya son ciphertext E2E). | Lectura de `migrate.go` + `main.go` (el comando conecta con `--nats-url`/`--ca`); ata con la evidencia N2. | Ejecutar `migrate-to-kv` solo en loopback o con TLS. No correrlo contra un nodo que sirva clientes con ACL hasta resolver N2. | + +### N5 (failover del cliente) — sin hallazgo de seguridad + +Al reconectar a otro nodo, el cliente **re-autentica**: `RefreshSession`/el reconnect automático de nats.go reusan `natsOpts` (incluyen `nats.Nkey` y `nats.Secure(TLS)`), así que el nuevo nodo re-ejecuta el handshake nkey + verifica firma sobre su propio nonce + `IsAuthorized` + (con ACL) deriva permisos **frescos de su store**. No hay ventana operando sin re-autenticar ni con permisos ACL viejos. Los caches preservados (`keyCache`/`signCache`) son las propias claves del peer — reusarlas no es inseguro. *Nota funcional (no de seguridad):* con SQLite por-nodo el estado del control plane diverge entre nodos, así que el failover de transporte funciona pero un nodo puede no conocer una room creada en otro — esa es la razón por la que el control plane KV (no cableado) es necesario. + +--- + +## Regresión — ¿0003 rompió algún fix de 0001/0004/0005? + +**No.** Todos los tests de auditoría/re-auditoría/gap previos siguen verdes y siguen **rechazando** sus ataques. Re-ejecución sobre `df3b62a`: + +``` +$ CGO_ENABLED=0 go test ./pkg/membership/ ./pkg/client/ -run \ + 'TestAudit_|TestGap_|TestReaudit_|TestReplicatedNonce|TestSubjectACL|TestRefreshSession|TestClientFailover' -v -count=1 +--- PASS: TestAudit_HorizontalMetadataLeak (autz por pertenencia, H3) +--- PASS: TestAudit_OwnerSpoof (owner binding, H6) +--- PASS: TestAudit_DoSBodyLimitNoAuth (body limit pre-auth) +--- PASS: TestAudit_NonceCachePoisonPreAuth (nonce poison: autorizar antes de cachear) +--- PASS: TestAudit_NoSubjectACL (confidencialidad de contenido E2E) +--- PASS: TestReaudit_DoSConcurrency (in-flight global cap, N2 del 0006) +--- PASS: TestReaudit_SigNilSpoof (sig-nil drop en rooms SignMsgs, N3 del 0006) +--- PASS: TestReaudit_H4_WildcardMetadataLeak (ACL confina Subscribe(">")) +--- PASS: TestReplicatedNonceRejectsCrossNodeReplay (la API KV cierra el replay — cuando se usa) +--- PASS: TestSubjectACLIsolation (aislamiento por subject single-node) +--- PASS: TestRefreshSessionGainsNewRoom (refresh re-deriva permisos) +--- PASS: TestClientFailoverAcrossNodes (failover transparente) +ok github.com/enmanuel/unibus/pkg/membership +ok github.com/enmanuel/unibus/pkg/client + +$ CGO_ENABLED=0 go test ./cmd/membershipd/ -run 'TestClusterConfigPolicy|TestSplitRoutes' -v +--- PASS: TestClusterConfigPolicy (8 sub-casos: rechaza cluster público sin secreto / sin TLS / TLS parcial) +--- PASS: TestSplitRoutes +``` + +Suite completa + toolchain: + +``` +$ 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 + +$ govulncheck ./... +=== Symbol Results === +No vulnerabilities found. +Your code is affected by 0 vulnerabilities. +(0 en paquetes importados; 13 en módulos requeridos pero NO alcanzables por el código) +``` + +`govulncheck`: **0 vulnerabilidades alcanzables**, confirmando lo que 0005a anotó (el bump de nats-server/nats.go/modernc sigue limpio en superficie alcanzable). + +--- + +## Confirmaciones — qué SÍ resiste (verificado, no asumido) + +- **N1 — auth de routes real y mutua.** Un nodo con el password de cluster incorrecto **no forma route** (`TestClusterRejectsBadRouteAuth`: impostor 0 routes, baseline del nodo legítimo intacto). Un nodo cuyo cert no está firmado por la CA del bus **no establece route en ninguna dirección** (`TestClusterRejectsUnsignedNode`, `RequireAndVerifyClientCert`). El listener de routes es un plano server-to-server distinto del de clientes; **no** reusa el authenticator nkey. `validateClusterConfig` rechaza arrancar un cluster en bind público sin secreto **y** sin TLS mutuo completo (8 sub-casos verdes). +- **N1.1 — la ACL aísla subjects CROSS-NODE** con posture homogénea. Ataque efímero (cluster 2 nodos ACL): `eve's cross-node sub to "room.cross.a" denied: Permissions Violation for Subscription`; alice publica en el nodo 0 y eve (nodo 1) **no recibe nada** — la ACL se aplica en el nodo de entrega antes del fan-out. +- **N2 — la ESCRITURA al KV está denegada.** La ACL no concede `$KV.>` para publicar; un peer no puede escribir los buckets ni escalar privilegios por esa vía. Verificado dos veces: el primer intento de seed bajo ACL falló con `Permissions Violation for Publish to "$KV.UNIBUS_users..."`, y el ataque N2 confirmó `eve KV Put rejected by ACL (good)`. **La fuga es solo de lectura** (grave igualmente), no de escritura. +- **N3 — la API de nonce replicado funciona cuando se usa.** `TestReplicatedNonceRejectsCrossNodeReplay` (que llama `UseReplicatedNonces` explícito) rechaza el replay cross-node (401). El `Create` atómico vía RAFT es linealizable (sin race de visibilidad inter-nodo). El handler valida skew → firma → allowlist **antes** de reclamar el nonce (`auth.go:189-238`), así que un atacante no autenticado **no** puede inundar el bucket de nonces (anti-DoS preservado). El problema es solo que el binario no la invoca (N3 arriba). +- **N5 — el failover re-autentica** (nkey+TLS) y deriva permisos frescos; sin ventana insegura. +- **N6 — migración** idempotente con backup consistente (`VACUUM INTO`); estado parcial recoverable. +- **Regresión completa** (auth firmada, anti-replay single-node, autz por pertenencia, DoS body+in-flight, fail-open guard, TLS forzado, sig-nil drop, nonce poison, owner spoof) verde + govulncheck limpio. + +--- + +## Recomendaciones priorizadas antes del deploy 0003f + +1. **[BLOQUEANTE] Cablear el nonce replicado en el binario.** En `membershipd`, cuando arranca con `--cluster-name` (o siempre que haya JetStream), llamar `srv.UseReplicatedNonces(js, replicas)` y **fail-fast** si el bucket no se crea. Sin esto, todo cluster tiene replay cross-node (N3). Test de no-regresión: replicar `TestAttack0008_N3` esperando 401 en el nodo B. +2. **[BLOQUEANTE antes de `decentralized: on`] Cerrar `$JS.API.>`.** Sustituir el grant amplio por permisos JetStream mínimos por-room (solo la API del stream/consumer de las rooms del peer) y **denegar explícitamente** los streams `KV_UNIBUS_*` (y `OBJ_*`). Opción robusta: mover el control plane KV a una NATS *account* separada, inaccesible desde la account de clientes. Hasta entonces, **no** activar `decentralized: on` y **no** correr `migrate-to-kv` contra un nodo que sirva clientes con ACL (N2/N6). +3. **[ALTA] Posture homogénea del cluster.** Garantizar (arranque/health) que todos los nodos corran `enforce`+ACL+TLS; un solo nodo débil exfiltra todo el data plane (N1.2). No exponer el puerto de cliente de ningún nodo sin enforce. +4. **[MEDIA] Completar el wiring del control plane KV** (selección de store + bootstrap del authenticator interno, el "ciclo bootstrap" pendiente) antes de afirmar HA del control plane; hoy es SQLite por-nodo → estado divergente (N5 nota). +5. **[MEDIA] RefreshSession en los clientes** (chat/worker/mobile/gateway) tras cambios de membresía, o refresh transparente; si no, la ACL es inutilizable y se desactivará (N4). +6. **[MEDIA] No vender R1 como HA.** El nonce/control plane en R1 es SPOF de auth; documentar y planificar R3 (N3 DoS). +7. **[BAJA] CA separada para routes** y secreto de cluster por archivo/env (no argv) (N1). +8. **[BAJA] `migrate-to-kv` solo en loopback o con TLS** (N6). + +--- + +## Gaps honestos de esta auditoría + +- **Todo in-process.** Varios `nats-server` embebidos en un proceso; sin 3 VPS reales. No se probó el **chaos test de red** del DoD del issue (matar 1/3, matar 2/3, partición/split-brain RAFT) — eso es 0003f y requiere despliegue real. El comportamiento de quorum se razonó, no se midió en red. +- **N2 modela el estado post-wiring.** El binario v0.7.0 no activa el control plane KV, así que el ataque N2 **simuló** el decentralized (poblar el KV sin auth y rebootear el store con la ACL de producción). La fuga es real a nivel API+ACL y se dispara en cuanto se cablee el KV **o** se corra `migrate-to-kv` en un nodo con ACL; no es explotable contra el binario tal cual mientras el store sea SQLite y no existan los buckets. +- **Object Store (blobs) no probado directo** vía `$JS.API.>`. Probable misma clase de fuga que N2 (los blobs son ciphertext E2E → impacto menor: metadata/tamaños), no verificado. +- **DoS por volumen del bucket de nonces** (millones de `Create` por tráfico autorizado legítimo) no medido bajo carga; solo se verificó que el atacante no-autenticado no lo alcanza. +- **`mobile`/`gateway`** no auditados para `RefreshSession` ni para reuso de sesión en failover. +- Los tests de ataque fueron **efímeros** (`pkg/membership/zzz_audit0008_attack_test.go`, creado/ejecutado/borrado); el working tree quedó en baseline (`git status --porcelain` vacío). Reproducir requiere recrearlos; sus cuerpos están descritos arriba. diff --git a/reports/0009-2026-06-07-unibus-cluster-hardening.md b/reports/0009-2026-06-07-unibus-cluster-hardening.md new file mode 100644 index 0000000..73b1653 --- /dev/null +++ b/reports/0009-2026-06-07-unibus-cluster-hardening.md @@ -0,0 +1,89 @@ +# 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_` 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. diff --git a/reports/0010-2026-06-07-unibus-android-native.md b/reports/0010-2026-06-07-unibus-android-native.md new file mode 100644 index 0000000..1b22bf8 --- /dev/null +++ b/reports/0010-2026-06-07-unibus-android-native.md @@ -0,0 +1,147 @@ +# Report 0010 — unibus app Android nativa (Compose + binding gomobile) + +- **Fecha:** 07/06/2026 +- **Autor:** Claude (agente Android) +- **Ámbito:** `projects/message_bus/apps/unibus` — binding `mobile/`, app `android/` +- **Estado:** done (mock primero, binding real cableado y listo para enchufar) +- **Rama:** `issue/android-native` (sub-repo `dataforge/unibus`), pusheada. NO mergeada a master. +- **Aislamiento:** todo el trabajo en worktree `/tmp/unibus_android`. Cero cambios en `~/fn_registry/.../apps/unibus` ni en el repo padre. + +## Resumen + +App Android nativa de unibus en Kotlin/Compose (Material 3, tema oscuro, acento +índigo/violeta) que replica el look & feel de la app web. Construida sobre un +binding gomobile rehecho que delega todo el cifrado de extremo a extremo en +`pkg/client` (el mismo que cualquier otro peer del bus). La iteración 1 corre +sobre datos mock para iterar el diseño; el repositorio real (binding) está +cableado y compilando detrás de la misma interfaz para enchufar el bus después. + +Las tres pantallas (Login, lista de rooms, chat estilo Element) se verificaron +en el emulador Pixel_API34: arrancan sin crash y son visualmente equivalentes a +la web. Capturas en `assets/`. + +## Cambios + +| Qué | Dónde | Por qué | +|---|---|---| +| Binding gomobile rehecho | `mobile/unibus.go` (package `mobile`) | Se borró en la limpieza de frontends; la app lo necesita. API plana sobre `pkg/client`. | +| Script de regeneración del .aar | `mobile/gen_aar.sh` | El `.aar` (38 MB) no se versiona; reproducible con un comando. | +| App Compose | `android/` (Gradle + Kotlin) | App nativa con E2E real en el dispositivo. | +| Capa de repositorio | `android/.../data/Repository.kt` + `BindingRepository.kt` | Aísla la UI; mock para diseño, binding para el bus real, misma interfaz. | +| Pantallas | `LoginScreen.kt`, `RoomListScreen.kt`, `ChatScreen.kt` | Réplica de `web/src` (Login.tsx, Sidebar.tsx, ChatPanel.tsx). | +| Tema | `ui/theme/Theme.kt` | Escala dark.* + brand índigo/violeta igual que el tema Mantine de la web. | +| go.work gitignored | `.gitignore` (raíz del sub-repo) | Resuelve el `replace fn-registry` con path absoluto desde el worktree, sin tocar `go.mod`. | + +### 1. Binding gomobile (`mobile/unibus.go`) + +Tipos planos gomobile-friendly (string/[]byte/int/bool/error/interfaces). No +reimplementa criptografía — cada método delega en `pkg/client`. API expuesta: + +- `GenerateIdentity(path)`, `NewSession(idPath, natsURL, ctrlURL, caPath)` → usa + `client.Connect` con la auth nueva (TLS pineado al CA + nkey si `caPath != ""`). +- `EndpointID`, `ConnectedServer`, `IsConnected`. +- `CreateRoom(subject, mode)`, `Join`, `RefreshSession` (contrato de membresía + issue 0006e: tras crear/unirse/invitar, `RefreshSession` antes de pub/sub). +- `Publish(text)`, `Subscribe(FrameListener)`, `ListRoomsJSON`. +- `Card`, `Invite`, `Kick`, `Request`, `Close`. + +`FrameListener` (interfaz implementada en Kotlin) documenta el contrato de hilo: +`OnFrame` llega en una goroutine de NATS, así que la implementación Kotlin salta +al hilo principal (`Handler(Looper.getMainLooper()).post { ... }`) antes de tocar +estado de Compose. + +Clases Java generadas: `com.unibus.core.mobile.{Mobile, Session, FrameListener}`. + +### 2. App Compose (`android/`) + +- Navegación por estado (KISS, sin lib de routing): Login → lista de rooms → chat. +- `AppViewModel` orquesta el estado observable; `UnibusRepository` desacopla la + fuente: `MockUnibusRepository` (en memoria, mock espejo de `mock.ts`) y + `BindingUnibusRepository` (sobre `unibus.aar`, cableado completo y compilando). +- Diseño: tokens de color `dark.6/7/8/9` + dimmed + brand `#6C47E6` vía + `LocalUnibusColors`; avatares con iniciales; candado/hash por política E2E; + badges de no leídos; chat estilo Element (avatar+nombre+hora+texto) + composer. + +## Verificación + +### Binding compila y .aar generado (sí) + +``` +$ go build ./mobile/ # con go.work (replace fn-registry absoluto) +BUILD_OK mobile + +$ gomobile bind -target=android -androidapi 21 -javapkg com.unibus.core \ + -o android/app/libs/unibus.aar ./mobile +# 26 s. Resultado: +android/app/libs/unibus.aar 38 MB +jni/{armeabi-v7a,arm64-v8a,x86,x86_64}/libgojni.so # 4 ABIs +classes.jar -> com/unibus/core/mobile/{Mobile,Session,FrameListener}.java +``` + +### APK compila (sí) + +``` +$ cd android && ./gradlew assembleDebug --no-daemon +BUILD SUCCESSFUL in 1m 9s +37 actionable tasks: 37 executed + +APK: android/app/build/outputs/apk/debug/app-debug.apk (53 MB) +package: com.unibus.app versionName=0.1.0 launchable-activity .MainActivity +ABIs empaquetadas: lib/{arm64-v8a,armeabi-v7a,x86,x86_64}/libgojni.so +``` + +Toolchain: AGP 8.5.2, Gradle 8.7, Kotlin 1.9.24, Compose BOM 2024.06.00, +compileSdk 34, minSdk 21 (= `-androidapi 21` del bind), Java 17. + +### Verificación visual en emulador (sí — sin crash) + +Instalado y lanzado en `emulator-5554` (Pixel_API34, KVM). `adb logcat` sin +`FATAL`/`AndroidRuntime`. Las 3 pantallas son equivalentes a la web: + +- `assets/unibus-android-login.png` — Login: candado de marca, "unibus", campos + Identidad/Contraseña, botón Conectar. +- `assets/unibus-android-rooms.png` — lista de rooms: avatar+handle, buscador, + candado/hash, hora, último mensaje, badges de no leídos (3, 1) en violeta. +- `assets/unibus-android-chat.png` — chat estilo Element: header con candado + + "cifrada · E2E", mensajes avatar+nombre+hora+texto (nombre propio en violeta), + composer redondeado + send. + +## Ruta del APK + +``` +/tmp/unibus_android/android/app/build/outputs/apk/debug/app-debug.apk +``` + +(Worktree efímero. El APK se regenera con `cd android && ./gradlew assembleDebug` +tras `./mobile/gen_aar.sh`.) + +## Gaps / pendientes + +- **El binding NO está conectado en la UI todavía** — por diseño (la tarea pedía + mock primero). Para activar el bus real: instanciar `BindingUnibusRepository` + en `MainActivity` con las URLs del bus y pasarlo a `AppViewModel`; las + pantallas no cambian. Falta UI de configuración del endpoint (natsURL/ctrlURL) + y de carga del `ca.crt` (hoy se espera en `assets/ca.crt`, opcional → plaintext). +- **`ListMembers` no existe en `pkg/client`** — se expone `ListRoomsJSON` + (vía `ListMyRooms`). Listar miembros requiere primero exponerlo en el cliente. +- **Password no desbloquea la identidad aún** — `LoadOrCreateIdentity` crea/lee + la clave directamente; el campo password es UI-only de momento. +- **Metadata de room en el binding es parcial** — `ListRoomsJSON` da + subject/encrypted/role/epoch; `lastMessage`/`unread`/`messages` los rellena hoy + el mock. Con el bus real habrá que derivarlos del stream + persistencia local. +- **`.aar` no versionado** (38 MB, regenerable) — reviewer debe correr + `./mobile/gen_aar.sh` antes de compilar. Requiere Go + gomobile + NDK. +- **Verificación solo en x86_64 (emulador)** — no probado en hardware ARM físico. +- **`go.work` local** — necesario solo al construir el binding desde un worktree + fuera del árbol del registry; en checkout normal el `replace` relativo resuelve. + +## Notas (onboarding) + +Para retomar: + +1. `./mobile/gen_aar.sh` regenera `android/app/libs/unibus.aar` (Go+gomobile+NDK). +2. `cd android && ./gradlew assembleDebug` → APK en `app/build/outputs/apk/debug/`. +3. Diseño en `android/app/src/main/java/com/unibus/app/ui/` (espejo de `web/src/`). +4. Para enchufar el bus real: cambiar el repo del `AppViewModel` de + `MockUnibusRepository` a `BindingUnibusRepository(context, natsURL, ctrlURL)`. + La interfaz `UnibusRepository` es el único punto de contacto UI↔datos. diff --git a/reports/0011-2026-06-07-unibus-cluster-deploy.md b/reports/0011-2026-06-07-unibus-cluster-deploy.md new file mode 100644 index 0000000..903714c --- /dev/null +++ b/reports/0011-2026-06-07-unibus-cluster-deploy.md @@ -0,0 +1,173 @@ +# 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-repo `dataforge/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 ``/`` de los **comentarios**, + que el guard `grep -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.sh` generó 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 el + `cluster.pass` quedan gitignored en `secrets/`+`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_DEBUG` retirado de los 3 `cluster.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/tcp` + públicas y `6250/tcp` restringido a homer+datardos. +- `go vet` + `go test ./pkg/embeddednats/ ./pkg/membership/` → ambos `ok`. + +## Gaps / pendientes + +- **Push a Gitea pendiente:** los 2 commits de fix están en `master` local del sub-repo + `unibus`, **no pusheados**. El operador debe `git 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: implementar `user add + --store kv` que 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` (en `pass`). +- **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-name` de 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. diff --git a/reports/0012-2026-06-07-unibus-deploy-gaps-closed.md b/reports/0012-2026-06-07-unibus-deploy-gaps-closed.md new file mode 100644 index 0000000..c9901de --- /dev/null +++ b/reports/0012-2026-06-07-unibus-deploy-gaps-closed.md @@ -0,0 +1,180 @@ +# 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… 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… +``` + +### 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 \ + --nats-seeds nats://magnus,homer,datardos:4250 --ctrl-seeds https://...:8470 --messages 5 +connected: endpoint= 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.` 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 +``` diff --git a/reports/0013-2026-06-07-unibus-admin-panel.md b/reports/0013-2026-06-07-unibus-admin-panel.md new file mode 100644 index 0000000..f568bcf --- /dev/null +++ b/reports/0013-2026-06-07-unibus-admin-panel.md @@ -0,0 +1,158 @@ +# Report 0013 — unibus_admin: panel web de administración + +- **Fecha:** 07/06/2026 +- **Autor:** agente (Claude) +- **Ámbito:** app nueva `projects/message_bus/apps/unibus_admin` (sub-repo Gitea `dataforge/unibus_admin`), desplegada en magnus +- **Estado:** done + +## Resumen + +Panel web de administración del bus `unibus`: un único binario Go que sirve una SPA +Mantine embebida (`embed.FS`) y expone una REST API. El binario tiene la identidad +ADMIN del operador y media cada acción contra el plano de control del bus firmando +las peticiones; el navegador nunca firma, nunca habla NATS ni ve una clave privada. +Tres capacidades: **Cluster** (salud + posture de los 3 nodos), **Rooms** (listar, +crear, ver miembros, invitar, expulsar+rekey) y **Users** (allowlist del bus). +Verificado end-to-end contra el cluster vivo (magnus+homer+datardos, enforce+TLS+KV) +y desplegado en magnus tras Caddy con basic auth en un subdominio ofuscado. + +## Arquitectura + +``` +navegador (SPA Mantine, dark/índigo) + │ fetch /api/* — basic auth de Caddy + bind loopback (doble barrera) + ▼ +unibus_admin (gateway Go) — identidad ADMIN del operador + ├── pkg/client (unibus) CreateRoom / Invite / Kick / ListMyRooms (firma + cripto E2E) + ├── GET firmado a mano /rooms/{id}/members (membership.CanonicalRequest + cs.SignEd25519) + ├── GET /healthz CA-pinned posture de cada nodo del cluster + └── membership.Store (opc.) users (sólo con --db / acceso KV) + ▼ +cluster unibus (magnus + homer + datardos, --store kv --kv-replicas 3, enforce+ACL+TLS) +``` + +Decisión clave: el gateway **reutiliza el cliente del bus** para todo lo que lleva +criptografía (sellar la clave de room en create/invite, rotar en rekey) y construye +GETs firmados con la **única fuente de verdad** de la firma del bus +(`membership.CanonicalRequest` + `cs.SignEd25519`, ambos exportados) para las lecturas +que el cliente no expone (lista de miembros). No reimplementa firma ni cripto. La +identidad se carga de `pass unibus/operator-identity` (dev) o de un fichero 0600 +(`--identity-file`, producción). El TLS se ancla a la CA del bus (`busauth.LoadCATLSConfig`). + +Capa de repositorio (`internal/admin/Repo`): dos implementaciones —`busRepo` (real) y +`mockRepo` (`--mock`)— detrás de los mismos handlers REST, así la SPA tiene un único +camino de código para mock y real. + +## Cambios / artefactos creados + +| Artefacto | Qué | +|---|---| +| `main.go`, `embed.go` | flags, carga de identidad, wiring, embed de `web/dist` | +| `internal/admin/repo.go` | interface `Repo` + tipos wire (RoomView, MemberView, UserView, NodeHealth, Posture) | +| `internal/admin/repo_bus.go` | impl real: rooms (client), members (GET firmado), cluster (healthz CA-pinned), users (Store) | +| `internal/admin/repo_mock.go` | datos de muestra para iterar la UI | +| `internal/admin/server.go` | REST `/api/*` + servidor de la SPA embebida (fallback SPA) | +| `internal/admin/identity.go` | carga de la identidad del operador desde `pass` o fichero 0600 | +| `web/` | SPA React 19 + Vite 6 + Mantine v9 (dark/índigo): AdminShell + ClusterPage + RoomsPage + UsersPage + api.ts | +| `app.md`, `deploy/unibus-admin.service`, `deploy/README.md` | registro + artefactos de deploy | + +Repo Gitea: `dataforge/unibus_admin` (master, 3 commits). `git init -b master` dentro del +sub-repo se hizo inmediatamente tras el scaffold (apps/* gitignored en el padre). + +## REST API del gateway + +`GET /api/me` · `GET /api/cluster` · `GET /api/rooms` · `POST /api/rooms` · +`GET /api/rooms/{id}/members` · `POST /api/rooms/{id}/invite` · +`POST /api/rooms/{id}/kick` · `GET /api/users` · `POST /api/users` · +`POST /api/users/revoke` · `GET /healthz`. + +## Verificación (evidencia ejecutable) + +### Build (verde) + +``` +$ CGO_ENABLED=0 go build ./... # exit 0 +$ CGO_ENABLED=0 go vet ./... # exit 0 +$ cd web && pnpm build # tsc -b && vite build → dist/ (485 KB JS, 222 KB CSS) +$ CGO_ENABLED=0 GOOS=linux go build # ELF estático 18.9 MB +``` + +### Golden path — membershipd local, posture auth-off (sqlite) + +``` +POST /api/rooms {"subject":"ops.deploy","encrypt":true,...} → 201 {"room_id":"01KTHJF18N...","role":"owner"} +GET /api/rooms → [ops.deploy epoch 1 owner] (ListMyRooms vía client) +GET /api/rooms/{id}/members → [{endpoint:vI8HX..., role:owner, sign_pub, kex_pub}] (GET firmado) +POST /api/users {ana,...} → 201 added ; GET /api/users → operator+ana active +POST /api/users/revoke {ana} → 200 ; GET /api/users → ana=revoked +GET /api/cluster → magnus up, posture store:sqlite +``` + +### Error path + posture de PRODUCCIÓN — membershipd local enforce + TLS + nkey + +``` +GET /api/me → endpoint del operador derivado de la identidad cargada de pass +GET /api/cluster → magnus up, posture {enforce:true, acl:true, tls:true} (healthz https) +GET /api/rooms → 200 (ListMyRooms): exige conexión nkey+TLS y petición FIRMADA verificada bajo enforce +GET /api/rooms/{id}/members → 200 (GET firmado verificado bajo enforce) +POST /api/rooms (E2E) → 201 (firma + clave sellada) +GET /api/rooms/01XXXXNOEXISTE/members → 403 {"error":"forbidden: not a member of this room"} ← error path +``` + +### Contra el CLUSTER VIVO (magnus público, internet) + +``` +GET /api/cluster → magnus UP posture{enforce,acl,tls,cluster,store:kv} (86 ms) +GET /api/rooms → 3 rooms reales del operador (test.gapcheck.*) ← operador allowlisted en el cluster KV +``` + +### Despliegue en magnus (verificado en el host y por URL pública) + +``` +ssh magnus systemctl is-active unibus-admin.service → active +ssh magnus curl 127.0.0.1:8480/api/cluster + → [magnus UP 3ms, homer UP 39ms, datardos UP 7ms] los 3 nodos, posture enforce+acl+tls+cluster+kv + +# URL pública (subdominio ofuscado + basic auth de Caddy): +curl https://admin-.organic-machine.com/healthz → 401 (sin auth) +curl -u admin:*** https://admin-.../healthz → {"status":"ok"} +curl -u admin:*** https://admin-.../api/cluster → 3 nodos UP +``` + +### Visual (browser MCP) + +- Local mock: las 3 pestañas renderizan (Cluster 2/3 con datardos DOWN; Rooms 4 salas + con badges E2E/cleartext, epoch, rol, modal crear + drawer miembros; Users 4 con + ADMIN/MEMBER/REVOKED y revoke sólo en activos). +- URL pública desplegada: SPA renderiza la pestaña Cluster con **3/3 UP** reales + (magnus 0ms, homer 12ms, datardos 7ms) y el endpoint del operador, sin badge MOCK. + +## Estado del deploy en magnus + +- `unibus-admin.service` (systemd system, `Restart=always`), binario en + `/opt/unibus_admin/unibus_admin`, identidad 0600 en `/opt/unibus_admin/identity.json`. +- Puerto `8480` en loopback. Caddy site aditivo + `admin-.organic-machine.com` → basic auth (user `admin`) → `reverse_proxy 127.0.0.1:8480`. + Caddyfile validado y recargado; bloques existentes (gitea/registry/grafana/metrics/logs) + intactos; `membershipd-cluster.service` y `caddy` siguen `active`. +- Credenciales y URL en `pass`: `unibus/admin-panel-password`, `unibus/admin-panel-url`. +- Backup del Caddyfile previo a la edición: `/etc/caddy/Caddyfile.bak.unibus_admin.`. + +## Gaps / pendientes + +- **Users en el cluster (KV)**: el plano de control no expone endpoint HTTP de users + (viven sólo en el store). Con el cluster en `--store kv`, el gateway aún no abre el + KV, así que en producción la pestaña **Users queda degradada** (estado informativo: + `users_backend=none`). Funciona en single-node con `--db`. Se habilita con la vía de + alta KV de la rama `quick/0011-deploy-gaps` del repo unibus (otro agente) o con un + cliente KV admin. No se duplicó ese trabajo. +- **meta-leader / tamaño de quórum**: `/healthz` sólo da status + posture; el líder + RAFT y el nº de réplicas vivas requieren el endpoint de monitoreo de NATS (varz/jsz), + no expuesto. La pestaña Cluster muestra up/posture/latencia de cada nodo. +- **Invite a room E2E**: pide sign_pub + kex_pub del invitado en hex (la clave de room + se sella contra su X25519). No hay directorio de claves públicas; la UI los pide a mano. +- **Login propio de la app**: hoy la auth es la basic auth de Caddy + bind loopback. Un + login a nivel de app (sesión, roles en la UI) queda como mejora futura. +- **Vida útil (Capa 3 DoD)**: recién desplegado hoy; el criterio de >=7 días de uso real + sin romper queda por acumular. El service tiene `Restart=always` y healthz para + observabilidad. diff --git a/reports/0014-2026-06-07-unibus-users-http-admin-api.md b/reports/0014-2026-06-07-unibus-users-http-admin-api.md new file mode 100644 index 0000000..afe52b5 --- /dev/null +++ b/reports/0014-2026-06-07-unibus-users-http-admin-api.md @@ -0,0 +1,203 @@ +# unibus — API HTTP admin-only de gestión de usuarios + +- Slug: `unibus-users-http-admin-api` +- Fecha: 2026-06-07 +- Rama: `quick/users-http-admin` (basada en `quick/0011-deploy-gaps`) +- Worktree: `/tmp/unibus_usersapi` +- Versión: 0.9.0 → 0.10.0 + +## Resumen + +Se cierra la última asimetría del control plane de `unibus`. Antes, las rooms +tenían una superficie HTTP firmada (`POST /rooms`, `POST /rooms/{id}/invite`, +etc.) pero los usuarios solo se gestionaban por CLI local (`membershipd user +add|list|revoke`) o por acceso directo al store. Esto obligaba al panel de +administración a tener `--db` o acceso directo al KV del cluster, rompiendo el +modelo de "el panel habla firmado como un cliente más". + +Ahora los usuarios tienen API HTTP admin-only, simétrica con rooms: + +- `GET /users` — lista completa del allowlist (incluye revocados). +- `POST /users` — alta `{sign_pub, handle, role}`. +- `POST /users/{signpub}/revoke` — revocación (flip de status). + +El server ejecuta las mutaciones contra el **mismo** store privilegiado que ya +usa para las rooms (`s.store`): SQLite en single-node, JetStream KV replicado +(`UNIBUS_users`) en cluster. No se abre ninguna conexión KV nueva ni se usa la +identidad interna; el storage no cambia de sitio. El panel firma como admin y +deja de necesitar `--db`/KV directo. Funciona idéntico single-node y cluster. + +## Modelo + +- **Storage unificado (sin cambios).** `pkg/membership/store.go` define UNA + interface `Store` que abstrae rooms + members + sealed-keys + users, con dos + backends elegidos por `--store`: `sqlite` (single-node) y `kv` (cluster, + JetStream KV). El `Server` ya tiene el store privilegiado abierto y escribe ahí + para todo. Este cambio solo expone `s.store.AddUser/ListUsers/RevokeUser` por + HTTP; no altera dónde viven los datos. +- **HTTP admin-only y no acceso directo al KV.** El panel (gateway Go) habla + firmado como admin al control plane, igual que un cliente normal habla para + rooms. El server (que sirve el KV con permisos plenos en cada nodo) ejecuta la + mutación. El panel no necesita `--db`, ni la identidad interna `internal.id`, ni + correr en un nodo del cluster. +- **Bootstrap (huevo-gallina).** La CLI `membershipd user add --store kv` (de la + rama base) sigue existiendo SOLO para sembrar el admin #0: sin un admin sembrado + no hay quién firme el primer `POST /users`. A partir de ahí, toda la gestión es + HTTP admin-only. + +## Cambios por archivo + +### `pkg/membership/server.go` +- El contexto de request ahora lleva, además del `endpoint` del firmante, su + `sign_pub` hex. Motivo: `EndpointID(signPub) = base64url(sha256(signPub))` es + one-way, así que el endpoint no se puede revertir a la clave para mirar al + firmante en el allowlist. `withSigner(ctx, endpoint, pubHex)` + `signerPubHex(r)`. +- Helper `requireAdmin(w, r) (string, bool)`: default-deny. Devuelve `true` solo + si hay firmante autenticado Y `s.store.GetUser(pubHex)` confirma `role == admin` + y `status == active`; cualquier otro caso (sin firmante, no-admin, revocado, + error de store) → 403, fail-closed. A diferencia de `requireMember`, NO relaja + en AuthOff/dev: una operación que concede/revoca acceso nunca corre sin admin + verificado. +- Tres handlers, todos tras `requireAdmin`, registrados en el mux: + - `handleListUsers` → `s.store.ListUsers()` → `[]userJSON` (incluye status). + - `handleAddUser` → valida `sign_pub` (hex 64 vía `ValidateSignPubHex`) y role + (`admin|member`, vacío = member); `ErrUserExists` → 409 (no sobrescribe); + éxito → 201. + - `handleRevokeUser` → valida el path `{signpub}`; `s.store.RevokeUser(...)`; + desconocido/ya-revocado → 404; éxito → 200. +- `/healthz` sigue exento de auth; el resto hereda firma+nonce+TLS+enforce. + +### `pkg/membership/users.go` +- Nueva `ValidateSignPubHex(signPub) error` (hex de 64 chars = Ed25519 de 32 + bytes), single source of truth compartida por la CLI y los handlers. + +### `cmd/membershipd/users_cli.go` +- `validateSignPubHex` delega ahora en `membership.ValidateSignPubHex` (se elimina + el import `encoding/hex` que quedaba huérfano). Misma validación, una sola + implementación. + +### `pkg/client/client.go` +- `UserInfo` (tipo plano para el panel) + métodos firmados vía `doJSON`: + - `ListUsers() ([]UserInfo, error)` → `GET /users`. + - `AddUser(signPub, handle, role string) error` → `POST /users`. + - `RevokeUser(signPub string) error` → `POST /users/{signpub}/revoke`. +- Mirrors de wire types `userJSON` / `addUserReq`. + +## Contrato JSON de los endpoints + +Todos requieren las cabeceras de firma de transporte ya existentes +(`X-Unibus-Pub/Ts/Nonce/Sig`, sobre `CanonicalRequest`) Y que el firmante sea un +admin activo. Errores en el envoltorio estándar `{"error": "..."}`. + +### `GET /users` (admin-only) +Respuesta `200`: +```json +[ + {"sign_pub":"<64-hex>","handle":"alice","role":"admin","status":"active","created_at":"2026-06-07T..."}, + {"sign_pub":"<64-hex>","handle":"carol","role":"member","status":"revoked","created_at":"...","revoked_at":"..."} +] +``` +- `403` si el firmante no es admin activo. + +### `POST /users` (admin-only) +Body: +```json +{"sign_pub":"<64-hex>","handle":"carol","role":"member"} +``` +- `role` opcional (vacío = `member`). +- `201` `{"status":"added"}` en éxito. +- `400` si `sign_pub`/`handle` vacíos, hex inválido, o role fuera de `{admin,member}`. +- `409` `{"error":"user already registered (unchanged); revoke it first to replace"}` + si la clave ya está registrada (idéntica semántica que la CLI: no sobrescribe ni + eleva rol). +- `403` si el firmante no es admin activo. + +### `POST /users/{signpub}/revoke` (admin-only) +- Sin body. +- `200` `{"status":"revoked"}` en éxito (flip de status, sin hard-delete: la + identidad queda auditable; `IsAuthorized` la deniega en ambos planos al instante). +- `400` si `{signpub}` no es hex de 64 chars. +- `404` si no hay user activo con esa clave. +- `403` si el firmante no es admin activo. + +## Authz admin (default-deny) + +``` +firma+nonce+TLS+enforce (middleware existente) → requireAdmin + │ + signerPubHex(r) ausente ───────────────────────► 403 + GetUser(pubHex) error / role!=admin / status!=active ► 403 + admin activo ──────────────────────────────────► handler +``` + +No se baja la posture: la firma anti-replay, el enforce, el TLS y la ACL por +subject quedan intactos. La autorización admin se suma encima, consultando el +store en cada request (un admin recién revocado es denegado de inmediato). + +## Métodos del cliente (para el panel) + +```go +type UserInfo struct { + SignPub, Handle, Role, Status, CreatedAt, RevokedAt string +} + +func (c *Client) ListUsers() ([]UserInfo, error) +func (c *Client) AddUser(signPub, handle, role string) error +func (c *Client) RevokeUser(signPub string) error +``` + +El panel (`unibus_admin`) construye un `client.Client` con la identidad del admin +(la misma que firma para rooms) y llama estos tres métodos. Un `403` del server se +propaga como `error`. La pestaña Users del panel deja de necesitar `--db` o acceso +KV directo. + +## Evidencia ejecutable + +Build sin CGO con `go.work` externo (el worktree vive en `/tmp`, donde el +`replace fn-registry => ../../../../` del go.mod no resuelve; no se commitea): + +``` +printf 'go 1.26.4\nuse /tmp/unibus_usersapi\nreplace fn-registry => /home/enmanuel/fn_registry\n' > /tmp/usersapi.work +GOWORK=/tmp/usersapi.work CGO_ENABLED=0 go vet ./... # exit 0 +GOWORK=/tmp/usersapi.work CGO_ENABLED=0 go build ./... # exit 0 +GOWORK=/tmp/usersapi.work CGO_ENABLED=0 go test ./pkg/membership/ ./pkg/client/ -count=1 +# ok github.com/enmanuel/unibus/pkg/membership 8.399s +# ok github.com/enmanuel/unibus/pkg/client 6.166s +``` + +Tests nuevos (verbose): + +``` +=== RUN TestUsersHTTP_NonAdminForbidden --- PASS (403 en GET/POST/revoke) +=== RUN TestUsersHTTP_AdminRoundtrip --- PASS (add 201 → list activa → revoke 200 → list revocada) +=== RUN TestUsersHTTP_Validation --- PASS (hex 400, role 400, re-alta 409, fila intacta) +=== RUN TestClientUsersAdminAPI --- PASS (cliente admin add/list/revoke; member → 403 en los 3) +``` + +Cobertura de los casos pedidos: +- **403 no-admin** en los tres endpoints (`TestUsersHTTP_NonAdminForbidden`, + y la mitad no-admin de `TestClientUsersAdminAPI`). +- **Roundtrip admin** add → list (aparece, activa) → revoke (status revocado) + (`TestUsersHTTP_AdminRoundtrip`, `TestClientUsersAdminAPI`). +- **Validación**: hex inválido → 400, role inválido → 400, re-alta → 409, y + verificación de que el 409 no muta la fila existente (`TestUsersHTTP_Validation`). +- **Test de cliente** contra un membershipd embebido bajo enforce + (`TestClientUsersAdminAPI`). + +## Gaps / notas para el integrador + +- **Bootstrap sigue por CLL.** El admin #0 se siembra con `membershipd user add + --store kv` (rama base). La API HTTP no puede crear el primer admin (no habría + quién firme). Documentado en `app.md` (gotcha + growth log). +- **Revoke de admins.** Nada impide a un admin revocarse a sí mismo o al último + admin. No se añadió un guard "no te quedes sin admins" (fuera de alcance de esta + tarea; el flujo de bootstrap por CLI permite recuperarse). Candidato a endurecer + si el panel lo necesita. +- **`revoke` mapea desconocido y ya-revocado a 404 indistintamente.** `RevokeUser` + del store no distingue ambos casos; se mapea a 404 con mensaje genérico. Si el + panel quiere idempotencia en revoke, conviene un cambio en el store (fuera de + alcance). +- **El gotcha histórico "ni auth en las rutas GET" de `app.md`** describe la + posture v1 y precede al enforce; no se tocó (es histórico, no de esta tarea). +- **Integración.** El operador mergea esta rama junto con `quick/0011-deploy-gaps`. + No se mergeó a master desde aquí. diff --git a/reports/0015-2026-06-07-unibus-web-wired.md b/reports/0015-2026-06-07-unibus-web-wired.md new file mode 100644 index 0000000..11d000a --- /dev/null +++ b/reports/0015-2026-06-07-unibus-web-wired.md @@ -0,0 +1,161 @@ +# Report 0015 — unibus: SPA web cableada al bus (gateway REST/SSE) + +- **Fecha:** 07/06/2026 +- **Autor:** agente (Claude Opus 4.8) + operador +- **Ámbito:** `projects/message_bus/apps/unibus` — gateway web (`cmd/webgw`) + capa de datos de la SPA (`web/src`) +- **Rama:** `quick/web-wire` (sub-repo `dataforge/unibus`), 2 commits, pusheada. NO mergeada (la integra el orquestador coordinando con el agente de estilo que trabaja en master). +- **Estado:** done (MVP funcional end-to-end verificado contra el cluster vivo) + +## Resumen + +La SPA de chat de unibus estaba 100% en datos mock (`src/mock.ts`). Ahora funciona de verdad contra el bus: un **gateway Go** (`cmd/webgw`) actúa como peer autenticado del bus (sesión `pkg/client` con la identidad del operador) y expone REST + SSE al navegador; la SPA tiene una **capa de datos** (`src/api.ts`) que reemplaza el mock — la sidebar lista rooms reales, el panel hace stream de mensajes reales descifrados por SSE y el composer publica de verdad. Verificado end-to-end por curl y en el navegador (login → ver rooms → enviar → recibir descifrado en vivo, incluido fan-out a varios clientes simultáneos). No se reestilizó ningún componente: solo cambió de dónde vienen los datos. + +## El gateway (`cmd/webgw`) + +Binario Go único. Mantiene la identidad del operador (desde `pass unibus/operator-identity` o un fichero 0600, solo en memoria) y una sesión `pkg/client` conectada al bus. Mismo *posture seam* que `unibus_admin` y `cmd/clientcheck`: `--ca` vacío = plaintext dev; `--ca ` = TLS + nkey en ambos planos. + +Archivos: +- `cmd/webgw/main.go` — flags, carga de identidad, wiring, arranque + shutdown. +- `cmd/webgw/identity.go` — carga de identidad/passphrase desde `pass`/fichero (gemelo de `unibus_admin/internal/admin/identity.go`). +- `cmd/webgw/gateway.go` — wrapper del `pkg/client`: tipos wire + operaciones de room. +- `cmd/webgw/hub.go` — fan-out: **una** suscripción al bus por room, multiplexada a N clientes SSE. +- `cmd/webgw/server.go` — superficie HTTP: auth por cookie, REST, SSE, static opcional. + +### Endpoints + +| Método y ruta | Acción de `pkg/client` | Notas | +|---|---|---| +| `POST /api/login` | — | Desbloquea sesión con la passphrase del operador (compare en tiempo constante). Emite cookie `unibus_session` HttpOnly. Única ruta `/api` sin sesión. | +| `POST /api/logout` | — | Invalida la sesión. | +| `GET /api/me` | `Endpoint()` | Identidad del operador que el gateway encarna. La SPA la consulta al montar para reanudar sesión. | +| `GET /api/rooms` | `ListMyRooms()` | Lista de rooms del operador. | +| `POST /api/rooms` | `CreateRoom(subject, policy)` | `{subject, encrypted}` basta; policy por defecto = encrypted+persisted+signed (Matrix-like). Acepta override `encrypt`/`persist`/`sign_msgs`. | +| `POST /api/rooms/{id}/join` | `Join(roomID)` | Idempotente; obtiene la clave de room para rooms cifradas. | +| `POST /api/rooms/{id}/send` | `Publish(roomID, body)` | El peer sella (AEAD) y firma antes de salir del proceso. | +| `GET /api/rooms/{id}/stream` | `Subscribe(roomID, handler)` | SSE: cada frame **descifrado** como evento `data:`. Historia primero (rooms persistidas, `DeliverAll` en el primer bind) y luego en vivo. Heartbeat `: ping` cada 25 s. | +| `GET /healthz` | — | Liveness sin auth. | + +### Decisiones de diseño + +- **Hub fan-out por room.** El `pkg/client` deriva el nombre del consumer durable por `(room, endpoint)`; dos `Subscribe` de la misma room desde el mismo operador competirían por el durable. El gateway mantiene **una** suscripción por room y reparte cada frame descifrado a todos los clientes SSE — así varias pestañas/clientes ven todos los mensajes y se abre como mucho una suscripción al bus por room. Conteo de referencias: la suscripción se cierra cuando se va el último cliente (con orden de locks consistente `g.mu → h.mu`, broadcast no bloqueante que descarta a un cliente atascado en vez de frenar la room). +- **`RefreshSession` gateado por posture.** Tras crear/unirse a una room, NATS congela los permisos por-subject; bajo ACL hay que refrescar la sesión (reconectar). En plaintext dev NO hace falta y además tiraría los streams SSE vivos, así que solo se llama cuando `--ca` está puesto. +- **Auth por cookie, no header.** `EventSource` no permite cabeceras personalizadas pero sí envía la cookie same-origin; por eso la sesión es una cookie HttpOnly. La passphrase se compara con `subtle.ConstantTimeCompare`. +- **Sin embed estático en build.** El gateway sirve la SPA opcionalmente desde `--web-dir` (no `go:embed`), para que `go build ./...` sea siempre verde sin exigir `pnpm build` previo. En dev se usa el proxy de vite; para servir el dist se pasa `--web-dir web/dist`. + +### Modelo de confianza E2E + +El contenido sigue **cifrado end-to-end en el bus**. El gateway puede leer el plaintext porque **actúa como el cliente del operador**: es un miembro legítimo de cada room y posee la clave de room K como cualquier peer; sella/abre en el lado servidor del peer. Es la misma confianza que tendría un cliente nativo de escritorio del operador. El cleartext solo cruza un canal SSE autenticado (cookie de sesión) sobre loopback (o TLS fronted en deploy). En la **fase 2** (wallet por-navegador con WebCrypto, identidad Ed25519 por usuario) el descifrado puede moverse al navegador y el gateway dejaría de ver plaintext. + +## La capa de datos de la SPA (`web/src`) + +Los componentes conservan su aspecto y props; solo cambió la fuente de datos (permitido: no se reestiliza). + +- `src/api.ts` — capa única de repositorio. Wrappers `fetch` (cookie same-origin) para login/logout/me y rooms list/create/join/send; `streamRoom()` abre un `EventSource` y entrega cada mensaje descifrado. Mappers wire→UI (`roomFromWire`, `messageFromWire`). +- `src/types.ts` — añade las formas wire del gateway (`MeInfo`, `RoomWire`, `MsgWire`) junto a los tipos UI existentes. +- `src/App.tsx` — al montar consulta `/api/me` para reanudar sesión; si no hay (401), muestra Login. Logout llama al gateway. +- `src/Login.tsx` — el campo de contraseña desbloquea la sesión del gateway (passphrase del operador); estados básicos de carga y error. El handle es solo nombre a mostrar en esta iteración. +- `src/ChatShell.tsx` — carga rooms de `/api/rooms` con estados loading / empty / error; mismo layout `Flex`. +- `src/ChatPanel.tsx` — stream de mensajes por SSE para la room activa (dedup por id); el composer publica por el gateway. Sin inserción optimista: el eco del propio mensaje vuelve por SSE con el id de frame real, evitando duplicados. +- `src/vite.config.ts` — proxy de dev `/api` (REST + SSE) → gateway en `:8481`. + +`mock.ts` se deja intacto (ya no se importa) para no chocar con el trabajo de estilo paralelo en master. + +## Verificación (evidencia ejecutable) + +Entorno: cluster vivo local `membershipd --bind 0.0.0.0` (control plane HTTP `:8470`, NATS `:4250`), **plaintext dev** (https en `:8470` no responde; http `/healthz` → `{"status":"ok"}`). Identidad operador desde `pass unibus/operator-identity`. + +### Build + +``` +$ CGO_ENABLED=1 go build ./... # módulo entero → EXIT 0 +$ CGO_ENABLED=1 go vet ./cmd/webgw/ # EXIT 0 +$ gofmt -l cmd/webgw/ # (vacío) +$ cd web && pnpm build # tsc -b && vite build + ✓ 6948 modules transformed. + dist/assets/index-*.js 411.85 kB + ✓ built in 2.09s +``` + +### Gateway arranca y conecta al bus + +``` +[webgw] operator endpoint: vI8HXcintzK-oTAz_AgSErXBUeIygQzGMQlCRTnzcWo +[webgw] control plane: http://127.0.0.1:8470 (+0 failover) +[webgw] bus TLS+nkey: OFF (plaintext dev) +[webgw] web gateway: http://127.0.0.1:8481 +``` + +### REST + auth (curl) + +``` +$ curl -s :8481/healthz -> {"status":"ok"} +$ curl -s -o /dev/null -w '%{http_code}' :8481/api/rooms -> 401 (sin sesión) +$ curl -c jar -d '{"passphrase":"..."}' :8481/api/login + -> {"endpoint":"vI8HXcin...","sign_pub":"48bc0dc8...729571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa"} +$ curl -b jar :8481/api/me -> {"endpoint":"vI8HXcin...", ...} +$ curl -b jar :8481/api/rooms -> [] (operador sin rooms al inicio) +``` + +### E2E cifrado: crear room → SSE → enviar → recibir DESCIFRADO + +Room efímera cifrada+firmada (misma policy que `clientcheck`): + +``` +$ curl -b jar -d '{"subject":"test.webwire.22a152c9","encrypt":true,"persist":false,"sign_msgs":true}' :8481/api/rooms + -> {"id":"01KTHQKFPTR8VYZGVKP1CDZBY8","subject":"test.webwire.22a152c9","encrypt":true,...,"role":"owner"} +# SSE abierto en background, luego 2 sends: +$ curl -b jar -d '{"body":"hola E2E desde webgw"}' .../send -> {"status":"sent"} +$ curl -b jar -d '{"body":"segundo mensaje cifrado"}' .../send -> {"status":"sent"} +# El SSE entregó AMBOS, descifrados: +: connected +data: {"id":"01KTHQKGPX...","sender":"vI8HXcin...","body":"hola E2E desde webgw","ts":1780859126493,"mine":true} +data: {"id":"01KTHQKGQ3...","sender":"vI8HXcin...","body":"segundo mensaje cifrado","ts":1780859126499,"mine":true} +``` + +### Historia / scrollback (room persistida) + +Mensajes enviados ANTES de suscribir se entregan al abrir el SSE (`DeliverAll`): + +``` +$ curl -b jar -d '{"subject":"test.webwire.persist.a8d5dfc8","encrypted":true}' :8481/api/rooms + -> {"id":"01KTHQMAY7MV479A9QWD48928F","encrypt":true,"persist":true,"sign_msgs":true,...} +$ curl -b jar -d '{"body":"historico-1"}' .../send ; curl -b jar -d '{"body":"historico-2"}' .../send +# SSE abierto DESPUÉS de enviar: +data: {"...","body":"historico-1",...} +data: {"...","body":"historico-2",...} +``` + +### Navegador (browser MCP, gateway sirviendo el dist en `:8481`) + +- Login con handle `leo` + passphrase → entra a ChatShell. +- Sidebar muestra las 2 rooms reales del bus (`webwire.22a152c9`, `webwire.persist.a8d5dfc8`) con candado E2E. +- Escribir en el composer + enviar → el mensaje aparece descifrado en el panel (sender + hora), composer se limpia. +- **Fan-out / segundo cliente:** con el navegador suscrito a la room, un `curl` independiente sobre la MISMA room recibió el mensaje descifrado, y un mensaje enviado por curl (`PING-LIVE-NAVEGADOR-check`) apareció **en vivo en el panel del navegador** (21:12) — confirma una suscripción al bus multiplexada a varios clientes. + +## Gaps / pendientes + +- **Login wallet multiusuario (FASE 2).** Hoy el gateway usa UNA identidad (la del operador) y la passphrase solo desbloquea la sesión del gateway; el handle es cosmético. La fase 2 es identidad Ed25519 por-navegador (WebCrypto), descifrado en el cliente y por-usuario; el modelo de confianza E2E se endurece (el gateway dejaría de ver plaintext). Está documentado en el header de `main.go`/`gateway.go`, no bloquea el MVP. +- **Handle legible del remitente.** La UI muestra el `endpoint id` crudo del remitente (`vI8HXcin...`). Resolver endpoint→handle vía `/users` o la lista de miembros de la room es trabajo de pulido (fase 2). +- **Historia en re-suscripción de rooms persistidas.** El consumer durable es por-operador (compartido entre pestañas/sesiones del gateway): al reabrir el SSE, resume desde su último ack y NO re-entrega la historia ya consumida. En la prueba en navegador, un re-montaje del panel dejó el panel sin la historia previa (los mensajes nuevos sí llegan). Para "todas las pestañas ven toda la historia" hace falta un buffer de scrollback en el gateway o consumers efímeros con `DeliverAll` por stream SSE. Documentado; no bloquea el chat en vivo. +- **Crear room desde la UI.** El MVP lista/streamea/envía; no hay botón "nueva room" en la SPA (las rooms se crean por gateway/admin). Es un añadido de UI menor. +- **Limpieza de rooms de prueba.** El `pkg/client` no expone borrar room ni *self-leave* (solo `Kick` a otros), así que las rooms `test.webwire.*` creadas en la verificación quedan en el control plane. No es posible limpiarlas con la API actual; queda anotado. +- **Sidebar: `lastMessage`/`unread`/hora.** Se rellenan neutros (no se inventan datos); alimentarlos del último frame por room es pulido futuro. +- **`go.work` local.** El worktree vive en `/tmp`, donde el `replace fn-registry => ../../../../` del `go.mod` resuelve a `/`. Se añadió un `go.work` local (override del replace al repo real) excluido de git (`info/exclude`). En el repo en su ubicación normal no hace falta. + +## Cómo ejecutarlo + +```bash +# 1. gateway (operador desde pass, bus local plaintext) +go run ./cmd/webgw --identity-pass unibus/operator-identity \ + --unlock-pass-entry unibus/admin-panel-password \ + --ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250 --port 8481 + +# 2a. dev: vite con proxy a :8481 +cd web && pnpm install && pnpm dev # http://localhost:5181 + +# 2b. o servir el dist desde el propio gateway +cd web && pnpm build +go run ./cmd/webgw ... --web-dir web/dist # http://127.0.0.1:8481 + +# secured cluster: añadir --ca ca.crt y URLs https:// / seeds adicionales (como clientcheck) +``` diff --git a/reports/0016-2026-06-07-unibus-nats-metrics-clean.md b/reports/0016-2026-06-07-unibus-nats-metrics-clean.md new file mode 100644 index 0000000..68cd181 --- /dev/null +++ b/reports/0016-2026-06-07-unibus-nats-metrics-clean.md @@ -0,0 +1,226 @@ +# Report 0016 — unibus: métricas profundas de NATS/JetStream (limpio, sin debug-log) + +- **Fecha:** 07/06/2026 +- **Autor:** Claude (agente NATS metrics) +- **Ámbito:** `projects/message_bus/apps/unibus` (flag), `functions/infra` (parser del registry), `projects/fleet_monitoring/apps/unibus_exporter` (scraper), `projects/fleet_monitoring/hub` (dashboard + deploy) +- **Estado:** done (código + dashboard listos y probados; rollout al cluster pendiente, por diseño) + +## Resumen + +Se añadieron las métricas server-level de NATS/JetStream (msgs/s, conexiones, slow consumers, +memoria, KV bucket msgs, RAFT leader por stream, reinicios) a la monitorización del cluster +`unibus`, **de forma limpia**: desacoplando el endpoint de monitoring del nats-server (puerto 8222) +del debug-log verboso al que estaba acoplado. + +El problema original: el monitoring 8222 solo se abría con `UNIBUS_NATS_DEBUG=1`, que enciende el +log verboso del nats-server (rutas/RAFT/subjects de rooms a journald en claro), incompatible con el +endurecimiento del issue 0007. La causa era únicamente cómo estaba escrito el toggle: monitoring y +debug-log estaban acoplados sin necesidad. + +La solución es un flag dedicado `UNIBUS_NATS_MONITOR=1` que abre el monitoring loopback **sin tocar +el nivel de log**, un scraper local por nodo (modo nuevo del `unibus_exporter`) que lee `/varz`, +`/connz`, `/jsz` por loopback y empuja a VictoriaMetrics reusando las funciones del grupo +`fleet-metrics`, y un dashboard Grafana `unibus-nats`. **No se reinició el cluster** (eso es el +rollout consolidado del binario 0.11.0). + +## Cambios + +### Parte 1 — flag desacoplado en unibus (rama `quick/nats-monitor-flag`, pusheada) + +Repo: `dataforge/unibus`. Commit `1c93251`. + +| Archivo | Cambio | +|---|---| +| `pkg/embeddednats/embeddednats.go` | Función pura nueva `natsLogOpts(debugEnv, monitorEnv) (noLog, debug, trace, monitor)`; `StartServer` la usa. El monitoring se abre con `monitor` (no con `debug`). | +| `pkg/embeddednats/monitor_test.go` | Tests del desacoplamiento (tabla pura) + server real con `MONITOR=1` (8222 loopback) + server sin flag (cerrado). | +| `deploy/cluster/membershipd-cluster.service.d/nats-monitor.conf` | Drop-in systemd aditivo (`Environment=UNIBUS_NATS_MONITOR=1`). | +| `deploy/cluster/README.md` | Sección "NATS server metrics" con runbook de activación rolling + gate R3. | +| `app.md` | Bump 0.10.0 → 0.11.0 + growth log. | + +Diff esencial (lógica): + +```go +// natsLogOpts: pura, testeable. MONITOR=1 abre el endpoint dejando el log en silencio. +func natsLogOpts(debugEnv, monitorEnv string) (noLog, debug, trace, monitor bool) { + debug = debugEnv == "1" || debugEnv == "2" + trace = debugEnv == "2" + monitor = monitorEnv == "1" || debug // debug sigue implicando monitor (compat) + noLog = !debug + return noLog, debug, trace, monitor +} +// ... +opts.NoLog, opts.Debug, opts.Trace = noLog, debugNATS, traceNATS +if monitorNATS { + opts.HTTPHost = "127.0.0.1" // loopback hardcoded — el monitoring NUNCA es público + opts.HTTPPort = 8222 +} +``` + +El bind `127.0.0.1` se mantiene hardcoded: el endpoint no tiene auth y nunca debe ser alcanzable +por red. El `|| debugNATS` preserva el comportamiento viejo (DEBUG sigue abriendo el monitoring). + +### Función nueva del registry — `parse_nats_monitor_go_infra` + +Creada vía `fn-constructor` en el repo padre (`functions/infra/`). Análoga a `parse_unibus_health`, +mismo paquete `infra`, reusa el tipo `PromSample`. + +``` +func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error) +``` + +Convierte `/varz` + `/connz` + `/jsz?streams=1` en `[]PromSample` con labels `node`/`instance`. +Robusta: `/varz` es el core (error si no parsea); `/connz` y `/jsz` best-effort. Series: ver tabla +del dashboard abajo. Tag de grupo `fleet-metrics` (+ `nats`, `jetstream`, `monitoring`). + +### Parte 2a — modo NATS-local en `unibus_exporter` (rama `quick/nats-deep-metrics`, commit local) + +Repo: `dataforge/unibus_exporter` (sub-repo **sin remote aún**). Commit `dcdae92`. + +Segundo modo de scrape, componible con el modo healthz existente: + +| Archivo | Cambio | +|---|---| +| `config.go` | Bloque `nats_monitor {enabled, node, base_url}` + overrides `UNIBUS_NODE`/`UNIBUS_NATS_URL`; validación relajada (healthz, NATS o ambos). | +| `main.go` | `scrapeNATS()` lee loopback 8222 (`/varz`,`/connz`,`/jsz?streams=1`), llama `ParseNatsMonitor`, reusa `FormatPromExposition` + `PushPromRemote`. `getJSON` genérico. | +| `nats_mode_test.go` | `scrapeNATS` contra fixtures reales + varz caído + validación de config. | +| `testdata/nats_{varz,connz,jsz}.json` | Fixtures reales capturados de un nats-server 2.11.15. | +| `systemd/unibus-exporter-nats.service` | Unit del modo NATS-local (por nodo). | +| `unibus.nats.example.json` | Config de ejemplo nats-only. | +| `app.md` | Bump 0.1.0 → 0.2.0 + tabla de métricas NATS + growth log. | + +El modo healthz no se tocó. Como el monitoring de NATS es loopback-only y sin auth, este modo corre +**en cada nodo** (un exporter local por nodo), distinto del exporter healthz central de magnus. + +### Parte 2b — dashboard + deploy (rama `quick/nats-deep-metrics`, pusheada) + +Repo: `dataforge/fleet_monitoring`. Commit `dfd55dc`. + +| Archivo | Cambio | +|---|---| +| `hub/dashboards/unibus-nats.json` | Dashboard `unibus-nats` (uid), datasource `victoriametrics`, 12 paneles. Lo recoge el provider `fleet` existente (escanea el path de dashboards). | +| `hub/deploy_unibus_nats_exporter.sh` | Deploy del exporter en modo NATS-local por nodo. magnus → VM local sin auth; homer/datardos → ingesta pública con basic auth (`pass fleet/ingest-pass`, nunca en argv). Instala `unibus-exporter-nats.service` y sondea 8222 para avisar si el monitoring no está abierto. NO reinicia membershipd. | + +## Verificación (evidencia ejecutable) + +### P1 — flag (unibus, `CGO_ENABLED=0`) + +``` +$ go build ./... → BUILD_EXIT=0 +$ go test ./pkg/embeddednats/ -run 'TestNatsLogOpts|TestMonitor' -v +--- PASS: TestNatsLogOptsDecoupled (7 subcases: default off, monitor-only ⇒ log quiet, + debug⇒monitor, trace⇒debug+monitor, both, garbage ignorado ×2) +--- PASS: TestMonitorEndpointLoopback (server real MONITOR=1: /varz 200 en 127.0.0.1:8222) +--- PASS: TestMonitorDisabledByDefault (sin flag: MonitorAddr nil, endpoint cerrado) +$ go test ./pkg/embeddednats/ → ok 5.953s (suite completa, cluster_test incluido) +``` + +### Función del registry (`CGO_ENABLED=1 -tags fts5`) + +``` +$ go test ./functions/infra/ -run 'ParseNatsMonitor|Battery' → ok 0.004s + Golden verifica: nats_msgs_in_total=17, nats_msgs_out_total=17, nats_mem_bytes=18288640, + nats_jetstream_streams=3, kv_bucket_msgs{UNIBUS_users|rooms|members}=2, + nats_jetstream_raft_leader{KV_UNIBUS_users}=1. +$ ./fn index → Indexed 1451 functions (sin error en la función) +$ ./fn show parse_nats_monitor_go_infra → indexada OK +``` + +### P2a — exporter (`CGO_ENABLED=0`) + +``` +$ go build ./... → BUILD_OK +$ go vet ./... → VET_OK +$ go test ./... → ok 0.005s + TestScrapeNATSFromFixtures, TestScrapeNATSVarzDown, TestConfigNatsModeValidation +``` + +### E2E real — scraper contra un nats-server embebido vivo + +Se levantó un nats-server embebido **con el binario 0.11.0 real** (`UNIBUS_NATS_MONITOR=1`, 8222 +abierto, 3 KV buckets creados), se corrió el exporter en modo NATS-local apuntando a `127.0.0.1:8222` +y empujando a un VictoriaMetrics simulado (receptor HTTP local). El exporter empujó **29 series** +reales, recibidas y verificadas: + +``` +unibus_exporter starting: nodes=0 nats="http://127.0.0.1:8222 (node=probe)" ... +pushed 29 samples (nodes=0 nats=true) + +# series recibidas en el VM simulado (extracto): +nats_msgs_in_total{instance="probe",node="probe"} 17 +nats_msgs_out_total{instance="probe",node="probe"} 17 +nats_connections{instance="probe",node="probe"} 1 +nats_mem_bytes{instance="probe",node="probe"} 1.855488e+07 +nats_slow_consumers{...} 0 +kv_bucket_msgs{bucket="UNIBUS_users",...} 2 +kv_bucket_msgs{bucket="UNIBUS_rooms",...} 2 +kv_bucket_msgs{bucket="UNIBUS_members",...} 2 +nats_jetstream_raft_leader{stream="KV_UNIBUS_users",...} 1 +nats_jetstream_streams{...} 3 +nats_server_start_seconds{...} 1.78085962e+09 +nats_up{...} 1 nats_scrape_error{...} 0 +``` + +Esto valida toda la cadena: nats real → scrape loopback 8222 → `ParseNatsMonitor` → formato +Prometheus → push a VictoriaMetrics. El harness y los temporales se limpiaron tras la prueba. + +### Dashboard + +``` +$ python3 -c "json.load(...)" → OK uid=unibus-nats title='unibus — NATS server' panels=12 +``` + +Paneles: NATS up · conexiones · msgs/s in · slow consumers · JetStream msgs · reinicios (1h); +msgs/s por nodo (in/out) · conexiones por nodo · KV bucket msgs por bucket · memoria por nodo; +tabla "Leader RAFT por stream" · tabla "JetStream por nodo". + +## Runbook de activación (consolidada, HUMANO/orquestador — tras el rollout 0.11.0) + +El monitoring requiere reiniciar `membershipd` (reinicia el miembro RAFT del nodo). Se hace dentro +del rollout consolidado del binario 0.11.0, **nunca dos nodos abajo a la vez**. + +1. **Dependencia previa (registry):** commitear la función nueva al registry y push directo a master + (es aditivo, exento de TBD): `functions/infra/parse_nats_monitor.{go,md,_test.go}` + + `functions/infra/testdata/nats_*.json`. Sin esto el exporter no compila en otro entorno. +2. **Rollout del binario 0.11.0** a los 3 nodos en orden **magnus → homer → datardos**, instalando + en cada uno el drop-in `nats-monitor.conf` + `daemon-reload` + `restart membershipd-cluster`. + **Entre cada nodo**, esperar reconvergencia R3 (`followers_current=2/2` en los 6 KV buckets) y + `healthz` verde en el nodo reiniciado antes de pasar al siguiente (comandos en + `deploy/cluster/README.md` § "NATS server metrics"). +3. **Desplegar el scraper por nodo** (una vez que su 8222 está abierto): + `./hub/deploy_unibus_nats_exporter.sh magnus om` (y `homer homer`, `datardos dd`). +4. **Provisionar el dashboard:** copiar `hub/dashboards/unibus-nats.json` a + `/var/lib/grafana/dashboards` en magnus (mismo mecanismo que `unibus-cluster.json`; el provider + `fleet` lo recoge). +5. **Verificar:** series `nats_*`/`kv_bucket_msgs` en VictoriaMetrics y el dashboard `unibus-nats` + en Grafana. + +## Gaps / pendientes + +- **Función del registry sin commitear (dependencia de integración):** `parse_nats_monitor` está + creada, indexada y testeada en el working tree del padre `fn_registry`, pero NO se commiteó + (aislamiento: el prompt prohíbe tocar el padre; el orquestador la integra). Es el paso 1 del + runbook. El exporter la importa, así que debe ir al registry antes de integrar el exporter. +- **`unibus_exporter` sin remote Gitea:** el sub-repo aún no está sincronizado a `dataforge/`. El + commit `dcdae92` quedó **local** en la rama `quick/nats-deep-metrics`; el push se hará vía + `/full-git-push` (que crea el repo) o configurando el remote. Las otras dos ramas + (`quick/nats-monitor-flag` en unibus, `quick/nats-deep-metrics` en fleet) sí están pusheadas. +- **Validación contra el cluster vivo no realizada (por diseño):** el scraper se validó e2e contra + un nats-server embebido local con el binario 0.11.0 real, no contra magnus/homer/datardos. + Activar `UNIBUS_NATS_MONITOR` en datardos habría exigido reiniciar un nodo RAFT de producción con + otros agentes trabajando contra el cluster vivo — riesgo no justificado. Queda para el rollout. +- **`NRestarts` por proceso/stream no lo expone NATS 8222:** se emite `nats_server_start_seconds` + como proxy (un cambio de su valor = el nats-server reinició; el panel "Reinicios (1h)" usa + `changes()`). El contador de reinicios del proceso systemd (`NRestarts`) vendría de otra fuente. +- **Nit de linter (cosmético):** `parse_nats_monitor.go` usa `HasPrefix`+`TrimPrefix` donde el + linter sugiere `CutPrefix`. No afecta corrección; opcional de pulir. +- **Cluster NO reiniciado:** magnus/homer/datardos intactos (8222 cerrado, healthz OK). Correcto + según el encargo. + +## Ramas + +| Repo | Rama | Estado | +|---|---|---| +| `dataforge/unibus` | `quick/nats-monitor-flag` | commit `1c93251`, **pusheada** (NO merge) | +| `dataforge/fleet_monitoring` | `quick/nats-deep-metrics` | commit `dfd55dc`, **pusheada** (NO merge) | +| `dataforge/unibus_exporter` | `quick/nats-deep-metrics` | commit `dcdae92`, **local** (sub-repo sin remote) | +| `fn_registry` (padre) | working tree | función `parse_nats_monitor` sin commitear (paso 1 del runbook) | diff --git a/reports/0017-2026-06-07-unibus-rollout-0.11.0.md b/reports/0017-2026-06-07-unibus-rollout-0.11.0.md new file mode 100644 index 0000000..442759b --- /dev/null +++ b/reports/0017-2026-06-07-unibus-rollout-0.11.0.md @@ -0,0 +1,147 @@ +# Report 0017 — Rollout consolidado unibus 0.11.0 al cluster de 3 nodos (ROLLING, gate R3) + +- **Fecha:** 07/06/2026 +- **Autor:** agente (Claude Code) +- **Ámbito:** `apps/unibus` (membershipd) + `apps/unibus_admin` (panel) — cluster HA producción magnus + homer + datardos +- **Estado:** done — los 3 nodos en 0.11.0, R3 pleno, panel redeployado, sin rollbacks + +## Resumen + +Rollout rolling del binario `membershipd` 0.11.0 a los tres nodos del cluster, uno a uno +con gate de reconvergencia R3 entre cada nodo, activando en el mismo ciclo de restart el +monitoring NATS loopback limpio (`UNIBUS_NATS_MONITOR=1`, desacoplado del debug-log). Un +solo ciclo de restart por nodo desbloqueó las tres cosas previstas: la API `/users` (panel +admin, antes 404 con request firmado), las métricas NATS en `127.0.0.1:8222`, y el cierre +del gap del binario viejo (report 0012). Después, re-deploy del panel admin en magnus. El +cluster mantuvo quórum (2/3) en todo momento; cada nodo reconvergió a R3 (6/6 streams KV a +`followers_current=2/2`) antes de tocar el siguiente. No fue necesario ningún rollback. + +## Cambios + +| Fase | Acción | Resultado | +|---|---|---| +| 0 — Integrar | `git merge --no-ff quick/nats-monitor-flag` → master (commit `f31580d`), app.md → 0.11.0 | Sin conflictos; `web/` no tocado | +| 0 — Verificar | `go build ./...` + tests `membership`/`client`/`embeddednats`/`cmd/membershipd` | Todo verde; push a `origin/master` | +| 1 — Build | `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/membershipd_0.11.0 ./cmd/membershipd` | ELF static x86-64, sha `f6096a8e…`, `--help` OK | +| 2 — Rollout | magnus → homer → datardos: backup + drop-in `UNIBUS_NATS_MONITOR=1` + binario + restart + gate R3 | 3/3 reconvergidos, sin perder quórum | +| 3 — Verificar | healthz 3/3, `/users` firmado 200 3/3, 8222 3/3, sin debug, R3 6/6 a 2/2 | Todo verde | +| 4 — Panel | rebuild master (Users cableado) + backup + deploy magnus + restart `unibus-admin.service` | `/api/users` 200, end-to-end vía Caddy 200 | + +Detalle del flag (0.11.0): función pura `natsLogOpts(debugEnv, monitorEnv) (noLog, debug, trace, monitor)` +con `monitor = monitorEnv=="1" || debug` y `noLog = !debug`. Con `MONITOR=1` el endpoint de +monitoring abre en `127.0.0.1:8222` (bind loopback hardcoded) dejando el log del nats-server +en silencio (`NoLog` true). El acoplamiento inverso se mantiene (`DEBUG` implica `MONITOR`). +Drop-in systemd aditivo: `/etc/systemd/system/membershipd-cluster.service.d/nats-monitor.conf` +con `[Service]` + `Environment=UNIBUS_NATS_MONITOR=1` — no toca el unit base ni `cluster.env`. + +El binario nuevo es drop-in del unit existente: se verificó que acepta los 17 flags del +`ExecStart` (incluido `--internal-id-file`, presente solo en el unit de datardos). No se +modificó ningún unit ni `cluster.env`. + +## Verificación + +### Unit y baseline (confirmados, no asumidos) + +``` +ExecStart=/opt/unibus/membershipd … (EnvironmentFile=/opt/unibus/cluster.env) +HTTP_PORT=8470 NATS_CLIENT_PORT=4250 +healthz baseline (3 nodos): {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"} +8222 baseline: CERRADO en los 3 (esperado, sin drop-in) +binario viejo: magnus/homer 29337645 b; datardos 29374815 b (0012, con --internal-id-file) +``` + +### Hallazgo clave del gate R3 — métrica autoritativa + +La métrica `followers_current` de `/jsz` **solo es autoritativa consultada en el LEADER del +stream**. Un nodo follower (recién reiniciado) reporta su vista desactualizada de los otros +peers (`current=false`, `lag` constante) durante minutos sin que sea un problema real. La +señal autoritativa por nodo es el **`/healthz` del nats-server** (`127.0.0.1:8222/healthz`): +devuelve 503 si cualquier stream del que el nodo es miembro está lagging, 200 sólo cuando +todos están current. Se usó ese endpoint como gate primario por nodo, y la vista del +stream-leader para el conteo explícito `2/2`. + +### Gate R3 por nodo (orden magnus → homer → datardos) + +| Nodo | Backup | membershipd healthz | nats /healthz | meta (leader/size/pending) | 8222 /varz | [DBG]/[TRC] | quórum otros 2 | +|---|---|---|---|---|---|---|---| +| **magnus** | `membershipd.bak.20260607-192416Z` | 200 enforce+acl+tls+cluster+kv | 200 ok | homer / 3 / 0 | 200 | 0 | homer 200, dd 200 | +| **homer** | `membershipd.bak.20260607-193313Z` | 200 idem | 200 ok (t=0) | datardos / 3 / 0 | 200 | 0 | magnus 200, dd 200 | +| **datardos** | `membershipd.bak.20260607-193452Z` | 200 idem | 200 ok (t=0) | homer / 3 / 0 | 200 | 0 | magnus 200, homer 200 | + +Re-elecciones de meta-leader observadas (esperadas, quórum 2/3 intacto): inicial `homer` → +restart homer → `datardos` → restart datardos → `homer` (final). Tras completar los tres, la +vista del stream-leader (magnus) confirmó los 6 buckets KV +(`users/rooms/members/room_keys/rooms_by_member/nonces`) a `followers_current=2/2`: + +``` +streams KV a 2/2: 6/6 (stream-leader=magnus); meta-leader=homer cluster_size=3 pending=0 +``` + +### FASE 3 — verificación post-rollout (3 nodos) + +``` + magnus homer datardos +membershipd 200 200 200 +posture enforce+acl+tls+cluster+store=kv (homogénea) +/users firmado 200 200 200 (3 users idénticos — KV replicado R3) +8222 varz/jsz/connz 200/200/200 200/200/200 200/200/200 +nats /healthz ok ok ok +journal DBG/TRC 0 0 0 +``` + +`/users` firmado se verificó con un verificador throwaway (`client.Connect` + `ListUsers` +del propio `pkg/client`, identidad operator desde `pass unibus/operator-identity` en archivo +local 0600, TLS+nkey contra cada nodo por su IP pública; SAN del cert cubre las IPs). Antes +del rollout, un GET `/users` firmado daba 404 (ruta ausente en el binario viejo); ahora da +200 con la allowlist: + +``` +operator role=admin status=active +gapcheck_user role=member status=revoked (residuo E2E del report 0012) +gapcheck_user2 role=member status=revoked (residuo E2E del report 0012) +``` + +El verificador y la copia local de la identidad se eliminaron (`cmd/usercheck` borrado del +working tree; `/tmp/operator.id` con `shred`). Working tree de unibus limpio +(`master...origin/master`). + +### FASE 4 — deploy del panel admin + +``` +panel binario nuevo: /opt/unibus_admin/unibus_admin (sha 812831…), backup unibus_admin.bak.20260607-194114Z +service unibus-admin.service: active; journal limpio ("users backend: control-plane", "bus TLS+nkey: ON", "cluster nodes probed: 3") +GET /api/users (loopback 8480): HTTP 200 — lista real (3 users), ya no 502 +GET /api/cluster: 3 nodos up:true, posture homogénea, latencias 0/10/5 ms +end-to-end vía Caddy (basic auth admin): GET https://admin-…/api/users -> HTTP 200 con lista +end-to-end sin auth: HTTP 401 (Caddy basic_auth protege) +``` + +El binario del panel se reconstruyó desde master (frontend `web/dist` ya embebido, no se tocó +ningún `web/`). El Caddyfile no se modificó: el site (`reverse_proxy 127.0.0.1:8480` + +`basic_auth`) quedó intacto; no hizo falta `caddy reload` (mismo puerto/config). La cadena +completa navegador → Caddy(TLS+auth) → panel → bus firmado `/users` → KV R3 está probada. + +## Seguridad + +- Secretos siempre desde `pass`/path, nunca en git ni argv: identidad operator a archivo + local 0600 (borrada con `shred`); password del panel pasado a curl por stdin (`-K -`), + nunca en la línea de comandos. +- Monitoring vía `UNIBUS_NATS_MONITOR` (drop-in), **nunca** `UNIBUS_NATS_DEBUG`: confirmado + 0 líneas `[DBG]`/`[TRC]` en journald de los 3 nodos. 8222 bind loopback hardcoded. +- Backups reversibles por nodo conservados en `/opt/unibus/` (membershipd) y + `/opt/unibus_admin/` (panel) — listados arriba. +- Gate R3 estricto, un nodo abajo a la vez; quórum 2/3 nunca perdido. magnus (host crítico): + sólo se reinició `membershipd-cluster.service`. Posture homogénea mantenida. + +## Gaps / pendientes + +- **Verificación visual de la pestaña Users con navegador: no realizada.** Se verificó la + cadena funcionalmente end-to-end (Caddy basic_auth → panel → bus → `/api/users` 200 con la + lista real). No se abrió el navegador para no exponer el `admin-panel-password` en la URL + (el basic_auth de Caddy obliga a meter las credenciales en la URL o en un dialog nativo no + automatable de forma segura). El render de la SPA es determinista sobre `/api/users`, que + responde 200. +- Los usuarios `gapcheck_user`/`gapcheck_user2` (revoked) son residuos de los checks E2E del + report 0012, no usuarios reales; pueden limpiarse del KV si se desea (no bloquea nada). +- Binarios temporales en `/tmp` (locales y de los nodos) eliminados. Los backups `.bak.*` + se conservan a propósito para reversibilidad. diff --git a/reports/0018-2026-06-07-unibus-user-accounts.md b/reports/0018-2026-06-07-unibus-user-accounts.md new file mode 100644 index 0000000..8b0a54f --- /dev/null +++ b/reports/0018-2026-06-07-unibus-user-accounts.md @@ -0,0 +1,150 @@ +# Report 0018 — unibus: cuentas estilo WhatsApp (alta por invitación + baja por hard-delete) + +- **Fecha:** 07/06/2026 +- **Autor:** Claude (Opus 4.8) +- **Ámbito:** bus `unibus` (`pkg/membership`, `pkg/client`) + panel `unibus_admin` (gateway + SPA) +- **Estado:** done (código + tests + verificación e2e local). Gap de despliegue: el cluster vivo sigue en 0.11.0; el rollout a 0.12.0 lo hace el orquestador. + +## Resumen + +Capa de CUENTAS sobre el modelo wallet: la clave privada de cada usuario nace y vive en SU dispositivo; el servidor solo guarda la pública. El admin **crea** usuarios generando un **enlace de invitación** de un solo uso (nunca ve la privada) y **elimina** usuarios con **hard-delete** real (purga del allowlist), manteniendo `revoke` como acción distinta (status flip auditable). El usuario abre el enlace en su cliente, genera ahí su par de claves, y se registra con `POST /register` (autorizado por el token, no por firma admin). + +Dos repos tocados, ambos en ramas (NINGUNO mergeado a master): +- Bus `unibus` → rama `quick/user-accounts` (6 commits, pusheada). Bump 0.11.0 → **0.12.0**. +- Panel `unibus_admin` → rama `quick/user-accounts-ui` (3 commits, pusheada). Bump 0.1.0 → **0.2.0**. + +## Parte 1 — Bus (`quick/user-accounts`) + +### Hard-delete +`Store.DeleteUser(signPub) error` en ambos backends: SQLite `DELETE FROM users` y jetstreamStore `users.Delete(signPubHex)` (con `Get` previo para devolver `ErrNotFound` en un miss, ya que KV `Delete` es idempotente). Borra SOLO el allowlist; las membresías de rooms del ex-usuario quedan **inertes** (ya no puede autenticarse en ningún plano). NO se hace limpieza parcial de membresías — un owner expulsa/rekey su room para forward secrecy. `revoke` se mantiene intacto. + +Fix de consistencia colateral: el `GetUser` de SQLite ahora mapea `sql.ErrNoRows` → `ErrNotFound` como el KV y como documenta `store.go` ("every lookup miss returns ErrNotFound"). Antes devolvía el error crudo del driver. + +### Invites (nuevo backend de datos, ambos stores) +Tipo `Invite{Token, Handle, Role, ExpiresAt, Used, CreatedAt}` + campos de auditoría del consumo (`UsedAt/UsedSignPub/UsedKexPub`, vacíos hasta consumir). Métodos en `Store`: +- `CreateInvite(handle, role, ttlSecs) (Invite, error)` — token = 32 bytes `crypto/rand` en hex (64 chars); TTL default **7 días** si `ttlSecs <= 0`; role admin|member (vacío → member). +- `GetInvite(token)`, `ListInvites()`. +- `ConsumeInvite(token, signPub, kexPub) error` — valida (existe, no usado, no caducado) → registra el `signPub` con el handle/role del invite → marca usado. **Single-use atómico**: SQLite usa una transacción guardada por `used = 0`; KV usa un compare-and-swap sobre la revisión de la entry (mark-first). Burn-on-claim idéntico en ambos: si el `signPub` ya está registrado, devuelve `ErrUserExists` con el invite YA gastado. +- `CancelInvite(token)` — hard-delete del invite pendiente (para la futura cancelación admin). + +Persistencia: migración aditiva numerada `003_invites.sql` (raíz + copia embebida byte-idéntica) con tabla `invites` (+ índice `idx_invites_used`); bucket KV nuevo `UNIBUS_invites`. Nada borrado/recreado. + +### Endpoints HTTP (`pkg/membership/server.go`) +| Método/ruta | Auth | Qué hace | +|---|---|---| +| `POST /invites` | admin-only (`requireAdmin`) | body `{handle, role, ttl_secs?}` → `{token, expires_at}` | +| `GET /invites` | admin-only | lista de invites **pendientes** (filtra usados/caducados) | +| `DELETE /invites/{token}` | admin-only | cancela un invite | +| `DELETE /users/{signpub}` | admin-only | hard-delete (purga) | +| `POST /register` | **token** (sin firma admin) | redime un invite (ver contrato abajo) | + +`/register` es la única ruta mutante exenta de firma admin. Se separó `isRateExempt` (solo `/healthz`) de `isAuthExempt` (`/healthz` + `POST /register`): `/register` **salta la firma admin pero sigue sujeto al rate-limit por IP** del control plane (20/s, burst 40). Validación estricta de las dos claves hex (`ValidateSignPubHex` + nuevo `ValidateKexPubHex`, 32 bytes / 64 hex) ANTES de gastar el token; handle y role los fija el invite (sin escalado). + +### `pkg/client` +`CreateInvite/ListInvites/CancelInvite/DeleteUser` firmados como admin; `Register(token, signPub, kexPub)` SIN firma vía un nuevo helper `doUnsigned` (failover entre control planes + surfacing del error estructurado del server). + +### Tests (todos verdes, `CGO_ENABLED=0`) +- `pkg/membership/invites_test.go` — suite store-level sobre **ambos** backends (golden redeem, single-use, token desconocido, caducado [forzado], cancel, hard-delete) + burn-on-claim. +- `pkg/membership/invites_http_test.go` — HTTP: crear invite admin → `/register` sin auth 201 → aparece en `/users` → re-register 409 → caducado 410 → keys malformadas 400 → no-admin 403 en las 4 rutas admin → hard-delete purga (vs revoke). +- `pkg/client/invites_test.go` — e2e cliente: admin acuña invite → joiner NO registrado redime sin firma → aparece → hard-delete desaparece. + +## CONTRATO de `POST /register` (para el agente del cliente `/join`) + +La página `/join?token=` del cliente web consume este endpoint. Contrato exacto: + +**Request** — `POST /register`, `Content-Type: application/json`, **sin** cabeceras de firma: +```json +{ "token": "<64-hex>", "sign_pub": "<64-hex Ed25519>", "kex_pub": "<64-hex X25519>" } +``` +- `token`: el de `?token=` del enlace. +- `sign_pub`: clave pública Ed25519 generada localmente (identidad / firmas). +- `kex_pub`: clave pública X25519 generada localmente (key-exchange / sellado de room keys). +- El cliente genera el par localmente; la privada NUNCA se envía. `handle` y `role` NO van en el body (los fija el invite). + +**Respuestas:** +| Código | Cuerpo | Significado | +|---|---|---| +| `201` | `{"status":"registered"}` | alta correcta; la identidad ya puede conectarse al bus | +| `400` | `{"error":"token required"}` / `{"error":"sign-pub must be a 32-byte Ed25519 public key (64 hex chars)…"}` / `…kex-pub…` | body o claves malformadas | +| `404` | `{"error":"invalid or unknown invite token"}` | token inexistente | +| `409` | `{"error":"invite already used"}` | token ya consumido (single-use) | +| `409` | `{"error":"identity already registered"}` | el `sign_pub` ya está en el allowlist | +| `410` | `{"error":"invite expired"}` | invite caducado | +| `429` | `{"error":"rate limit exceeded"}` | rate-limit por IP | + +Notas para el cliente: el endpoint es público pero rate-limited; un token es de un solo uso, así que tras `201` el cliente debe guardar su identidad localmente (cifrada con la contraseña que elija el usuario) — un reintento con el mismo token dará `409`. + +## Parte 2 — Panel `unibus_admin` (`quick/user-accounts-ui`) + +### Gateway +- `POST /api/invites` → `client.CreateInvite`; `GET /api/invites` → `client.ListInvites`; `DELETE /api/users/{pub}` → `client.DeleteUser`. (list/add/revoke existentes intactos.) +- Doble vía como el resto de users: plano de control firmado (cluster) / store directo (`--db` single-node). +- **Base URL del enlace**: el gateway construye `<>/join?token=XXX` donde `<>` es la URL del **cliente final** (la app que hospeda `/join`, NO el panel). Se configura con el flag `--join-base-url https://chat.unibus.example` o la env `UNIBUS_JOIN_BASE_URL`; se expone en `/api/me` (`join_base_url`). Si no se configura, la SPA usa su propio origen como respaldo y avisa. + +### SPA (Mantine, dark, índigo) +- Botón **"Crear usuario"** → modal (handle + rol + caducidad en días) → `POST /api/invites` → muestra el **enlace copiable** + alerta de caducidad + aviso si el base URL no está configurado. +- Card **"Invitaciones pendientes"**: handle, rol, token parcial, caducidad, copiar enlace. +- Botón **"Eliminar"** por fila → modal de **confirmación FUERTE** que exige teclear el handle y advierte en rojo "BORRADO PERMANENTE" (distinto del revoke, que sigue siendo un `window.confirm`) → `DELETE /api/users/{pub}`. +- Se conserva "Añadir user (clave conocida)" para el caso avanzado. + +### Nota de build (revertida) +Para compilar el panel contra los métodos nuevos del cliente mientras `quick/user-accounts` no está en master, se apuntó temporalmente `go.mod` del panel: `replace github.com/enmanuel/unibus => /tmp/unibus_useraccounts`. **Revertido a `../unibus`** tras verificar (confirmado en el commit). Consecuencia: el panel en `../unibus` no compilará hasta que el bus llegue a master; por eso la rama del panel se mergea DESPUÉS del bus. + +## Verificación (evidencia ejecutable) + +### A) Contrato 0.12.0 contra un `membershipd` local (enforce, admin sembrado) +``` +$ membershipd user add --db … --handle operator --sign-pub --role admin +added user "operator" (0ce29047…) role=admin +# membershipd --bus-auth enforce → /healthz: {"posture":{"enforce":true,"acl":true,…},"status":"ok"} + +# admin crea invite (firmado) → token 39503d27ae93… +# POST /register con curl (SIN firma): +register -> HTTP 201 body: {"status":"registered"} +# dora aparece en /users: {"Handle":"operator",…} {"Handle":"dora",…} (2 filas) +# re-register MISMO token: +re-register -> HTTP 409 body: {"error":"invite already used"} +# hard-delete de dora → deleted ee64614216ae… +# /users tras delete: {"Handle":"operator","Role":"admin","Status":"active"} (1 fila — dora purgada) + +# errores de /register: +POST /register token inexistente -> {"error":"invalid or unknown invite token"} (HTTP 404) +POST /register sign_pub "abcd" -> {"error":"sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got 2 bytes"} (HTTP 400) +``` + +### B) Gateway del panel (`--mock`) +``` +GET /api/me -> {"users_backend":"sqlite","mock":true,"join_base_url":"https://chat.unibus.example"} +POST /api/invites -> {"handle":"ana","role":"member","token":"9455207f9098","join_url":"https://chat.unibus.example/join?token=9455207f9098…"} +GET /api/invites -> [{"handle":"ana","role":"member","join_url":"https://chat.unibus.example/join?token=…"}] +GET /api/users (antes) -> ["operator","ana","lucas","leo-revoked"] +DELETE /api/users/{pub} -> {"status":"deleted"} HTTP 200 +GET /api/users (después)-> ["operator","lucas","leo-revoked"] (ana purgada) +GET / -> HTTP 200 (SPA index) GET /join -> HTTP 200 (fallback SPA) +``` + +### C) Cluster vivo (sin tocar usuarios reales) +``` +# Salud (vía SSH, loopback TLS, CA pinada): +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"} +datardos {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"} + +# Confirmación de que /register NO está desplegado aún (rollout 0.12.0 necesario): +cluster magnus (0.11.0): POST /register -> HTTP 401 (no es ruta pública; pasa por auth y rechaza) +nodo local (0.12.0): POST /register -> HTTP 400 (ruta pública activa → token required) +``` + +### Build/test +- Bus (`/tmp/unibus_useraccounts`): `CGO_ENABLED=0 go build ./...` OK, `go vet ./pkg/...` OK, `go test ./...` OK (todos los paquetes verdes, incluida la suite nueva). +- Panel (contra el worktree, antes de revertir el replace): `CGO_ENABLED=0 go build ./...` OK, `go vet ./...` OK; SPA `pnpm build` OK (tsc + vite). + +## Gaps / pendientes + +- **Rollout del cluster a 0.12.0 (orquestador).** El cluster vivo está en 0.11.0; `/invites`, `/register` y `DELETE /users` no existen allí todavía. La verificación e2e de los endpoints NUEVOS contra el cluster vivo queda pendiente del rollout (verificado contra un nodo local 0.12.0 en su lugar). Confirmado con `/register -> 401` en los 3 nodos. +- **Orden de merge.** El panel (`quick/user-accounts-ui`) debe mergearse a master DESPUÉS de que el bus (`quick/user-accounts`) llegue a master, porque `go.mod` del panel apunta a `../unibus` y consume los métodos nuevos del cliente. +- **`kex_pub` validado pero no persistido en el allowlist.** `/register` valida el `kex_pub` (lo necesita el cliente para E2E) y lo guarda como dato de auditoría del consumo del invite (`used_kex_pub`), pero el allowlist de users sigue guardando solo `sign_pub` (igual que antes de este cambio). No hay un directorio global de `kex_pub`; sigue poblándose por-room al invitar a un room (sin cambio). No es regresión. +- **El cliente `/join` (web/) lo implementa otro agente.** Este trabajo deja `/register` listo y el contrato documentado arriba; `web/` no se tocó. +- **Cancelar invite desde el panel.** El bus expone `DELETE /invites/{token}` y `client.CancelInvite`, pero la UI del panel solo muestra/copia invites pendientes (no los cancela). Fácil de añadir; fuera del scope pedido. +- **`go.work` local.** El worktree del bus en `/tmp` usa un `go.work` (gitignored) con `replace fn-registry => /home/enmanuel/fn_registry` para resolver el registry desde `/tmp`. No commiteado. +- **Migración de invites en el snapshot SQLite→KV.** `importSnapshot/ExportSnapshot` (migración 0003c) no incluye invites; son efímeros (7 días) y de bajo valor, no se migran. Documentado, no bloqueante. diff --git a/reports/assets/unibus-android-chat.png b/reports/assets/unibus-android-chat.png new file mode 100644 index 0000000..9d00cce Binary files /dev/null and b/reports/assets/unibus-android-chat.png differ diff --git a/reports/assets/unibus-android-login.png b/reports/assets/unibus-android-login.png new file mode 100644 index 0000000..0207c67 Binary files /dev/null and b/reports/assets/unibus-android-login.png differ diff --git a/reports/assets/unibus-android-rooms.png b/reports/assets/unibus-android-rooms.png new file mode 100644 index 0000000..14ac3f9 Binary files /dev/null and b/reports/assets/unibus-android-rooms.png differ diff --git a/reports/unibus-grafana-monitoring-dashboard.png b/reports/unibus-grafana-monitoring-dashboard.png new file mode 100644 index 0000000..c6a9b51 Binary files /dev/null and b/reports/unibus-grafana-monitoring-dashboard.png differ