Files
message_bus/reports/0013-2026-06-07-unibus-admin-panel.md
T
egutierrez d43ffae3ae chore: auto-commit (17 archivos)
- reports/0001-2026-06-07-unibus-grafana-monitoring.md
- reports/0008-2026-06-07-unibus-admin-users-wired.md
- reports/0008-2026-06-07-unibus-decentralization-audit.md
- reports/0009-2026-06-07-unibus-cluster-hardening.md
- reports/0010-2026-06-07-unibus-android-native.md
- reports/0011-2026-06-07-unibus-cluster-deploy.md
- reports/0012-2026-06-07-unibus-deploy-gaps-closed.md
- reports/0013-2026-06-07-unibus-admin-panel.md
- reports/0014-2026-06-07-unibus-users-http-admin-api.md
- reports/0015-2026-06-07-unibus-web-wired.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 01:57:00 +02:00

8.3 KiB

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-<hash>.organic-machine.com/healthz              → 401 (sin auth)
curl -u admin:*** https://admin-<hash>.../healthz                  → {"status":"ok"}
curl -u admin:*** https://admin-<hash>.../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-<hash>.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.<ts>.

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.