d43ffae3ae
- 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>
159 lines
8.3 KiB
Markdown
159 lines
8.3 KiB
Markdown
# 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.
|