e1a7402ff1
New capability membershipd user add --store kv against a live cluster plus cmd/clientcheck end-to-end verification (issue 0011 gaps, report 0012). Adds the capability growth log entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
283 lines
16 KiB
Markdown
283 lines
16 KiB
Markdown
---
|
||
name: unibus
|
||
lang: go
|
||
domain: infra
|
||
version: 0.9.0
|
||
description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo."
|
||
tags: [service, messaging, nats, e2e]
|
||
uses_functions:
|
||
- generate_identity_go_cybersecurity
|
||
- seal_aead_go_cybersecurity
|
||
- open_aead_go_cybersecurity
|
||
- seal_key_box_go_cybersecurity
|
||
- open_key_box_go_cybersecurity
|
||
- sign_ed25519_go_cybersecurity
|
||
- verify_ed25519_go_cybersecurity
|
||
uses_types: []
|
||
framework: ""
|
||
entry_point: "cmd/membershipd"
|
||
dir_path: "projects/message_bus/apps/unibus"
|
||
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/unibus"
|
||
service:
|
||
port: 8470
|
||
health_endpoint: /healthz
|
||
health_timeout_s: 3
|
||
systemd_unit: unibus-membershipd.service
|
||
systemd_scope: user
|
||
restart_policy: always
|
||
runtime: systemd-user
|
||
pc_targets:
|
||
- lucas-linux
|
||
is_local_only: false
|
||
e2e_checks:
|
||
- id: build
|
||
cmd: "CGO_ENABLED=0 go build ./..."
|
||
timeout_s: 180
|
||
- id: vet
|
||
cmd: "CGO_ENABLED=0 go vet ./..."
|
||
timeout_s: 120
|
||
- id: unit
|
||
cmd: "CGO_ENABLED=0 go test ./..."
|
||
timeout_s: 180
|
||
- id: smoke
|
||
cmd: "CGO_ENABLED=0 go build -o /tmp/unibus_membershipd ./cmd/membershipd && /tmp/unibus_membershipd --http-port 18470 --nats-port 14222 --db /tmp/unibus_smoke.db --store-dir /tmp/unibus_blobs --nats-store /tmp/unibus_js &"
|
||
health: "http://127.0.0.1:18470/healthz"
|
||
timeout_s: 30
|
||
---
|
||
|
||
## Qué es
|
||
|
||
`unibus` es un bus de mensajería unificado sobre NATS + JetStream. Una capa fina
|
||
encima de NATS aporta lo que NATS no tiene: membresía de rooms, invitaciones con
|
||
reparto de clave, y cifrado extremo a extremo (E2E) del payload por room, con
|
||
rotación de clave activa (forward secrecy) al expulsar a un miembro.
|
||
|
||
Todos los participantes —procesos/workers, interfaces de chat humanas, agentes
|
||
LLM— son peers de primera clase que hablan el mismo protocolo y se diferencian
|
||
solo por las rooms a las que se unen y por lo que hacen con lo que reciben.
|
||
|
||
### Piezas
|
||
|
||
| Componente | Ruta | Rol |
|
||
|---|---|---|
|
||
| `membershipd` | `cmd/membershipd` | Service (control plane): metadata de rooms, directorio de miembros, reparto de claves selladas, object store de media. Arranca NATS embebido si no se pasa `--nats-url`. |
|
||
| librería cliente | `pkg/client` | La API que consume cualquier peer. Crear/unirse a rooms, invitar, publicar/suscribir, request/reply, kick con rotación de clave, media cifrada. |
|
||
| `worker` | `cmd/worker` | Peer demo: publica un contador incremental cada segundo a una room cleartext. |
|
||
| `chat` | `cmd/chat` | Peer demo: suscriptor en vivo (modo simple) + demo de cifrado E2E y forward secrecy (`--demo-encrypted`). |
|
||
|
||
### Dos planos
|
||
|
||
- **Control plane**: HTTP autoritativo en `membershipd`. Quién está en cada room,
|
||
sus claves públicas, y la clave de room `K` sellada por epoch para cada miembro.
|
||
- **Data plane**: NATS. Los mensajes (frames) viajan por subject; los blobs de
|
||
media NO viajan por el bus, se cifran y se suben al object store, y por NATS
|
||
solo viaja una referencia (hash + nonce).
|
||
|
||
### Criptografía (importada del registry, NO reescrita)
|
||
|
||
El cifrado E2E se compone de las 7 primitivas del capability group
|
||
`e2e-messaging` (`docs/capabilities/e2e-messaging.md`), importadas del paquete
|
||
`fn-registry/functions/cybersecurity`. Esta app no reimplementa ninguna
|
||
primitiva criptográfica.
|
||
|
||
## Ejemplo
|
||
|
||
Demo end-to-end con `go run` (NATS embebido, nada que instalar):
|
||
|
||
```bash
|
||
cd projects/message_bus/apps/unibus
|
||
|
||
# 1. Service de membresía/claves (NATS embebido en :4250, HTTP en :8470)
|
||
go run ./cmd/membershipd
|
||
|
||
# 2. En otra terminal: peer publicador (proceso) — publica ticks cada 1s
|
||
go run ./cmd/worker
|
||
|
||
# 3. En otra terminal: peer suscriptor (chat humano) — imprime cada tick en vivo
|
||
go run ./cmd/chat
|
||
|
||
# 4. Demo de cifrado E2E + forward secrecy (contra el membershipd ya corriendo):
|
||
# A crea room cifrada, invita a B, A publica (B descifra), A expulsa a B,
|
||
# A publica de nuevo en el nuevo epoch (B ya NO puede descifrar).
|
||
go run ./cmd/chat --demo-encrypted
|
||
```
|
||
|
||
Para apuntar a un NATS externo en producción: `--nats-url nats://host:4222` en
|
||
`membershipd`, `worker` y `chat`.
|
||
|
||
## Cuando usarla
|
||
|
||
- Cuando necesites un tejido de mensajería donde procesos, humanos y agentes LLM
|
||
sean peers uniformes (mismo protocolo, distinta política por room).
|
||
- Cuando quieras rooms cifradas E2E con forward secrecy (paridad con Matrix) sin
|
||
montar un Synapse: `room.ModeMatrix`.
|
||
- Cuando quieras fan-out cleartext rápido para telemetría/coordinación de
|
||
procesos: `room.ModeNATS`.
|
||
- Como sustituto de la capa de transporte Matrix de `agents_and_robots` (fase
|
||
posterior; v1 valida el bus de forma autónoma).
|
||
|
||
## Gotchas
|
||
|
||
- **El service NO está endurecido (v1).** No hay TLS, ni rate-limit, ni auth en
|
||
las rutas GET de lectura. Confía en la red interna. Las rutas mutantes
|
||
(`/rooms`, `/invite`, `/rekey`) sí exigen firma Ed25519 del owner sobre los
|
||
bytes canónicos de la request. Endurecer es fase posterior.
|
||
- **Identidad = secreto crítico.** El archivo de identidad (`worker.id`,
|
||
`chat.id`) contiene las claves privadas (Ed25519 + X25519). Se escribe 0600.
|
||
Perderlo = mensajes ilegibles, sin recuperación. Trátalo como una clave SSH.
|
||
- **Las rooms reciben un ULID fresco al crearse.** No hay "crear o unirse por
|
||
nombre": cada `CreateRoom` produce un room nuevo. Los peers demo cleartext
|
||
comparten el *subject* (NATS enruta por subject), así que worker→chat funcionan
|
||
aunque cada uno tenga su propio room id mapeado al mismo subject.
|
||
- **La media no viaja por el bus.** `PublishMedia` cifra, sube al object store y
|
||
publica solo un `BlobRef`. El receptor, si ve `Frame.Blob != nil`, descarga y
|
||
descifra con `FetchMedia`. El frame de media NO lleva payload inline (su nonce
|
||
vive en `BlobRef.Nonce`); `Subscribe` no intenta descifrar payloads vacíos.
|
||
- **Forward secrecy depende del rekey.** `Kick` rota `K` a un epoch nuevo y la
|
||
re-sella solo para los miembros restantes. El expulsado pierde acceso a los
|
||
mensajes publicados después del kick, pero conserva los anteriores (las claves
|
||
de epochs pasados no se borran: cifraban datos que ya podía leer).
|
||
- **NATS embebido escribe JetStream en disco.** `--nats-store` apunta a
|
||
`local_files/jetstream`; borrarlo resetea el historial persistido.
|
||
- **Build sin CGO.** Usa el driver `modernc.org/sqlite` (pure-Go) y el paquete
|
||
`cybersecurity` del registry compila limpio con `CGO_ENABLED=0`. NO requiere
|
||
`fts5` ni `gcc`.
|
||
|
||
## Convención de subjects
|
||
|
||
```
|
||
proc.<svc>.<canal> telemetría/coordinación de procesos (proc.test.ticks)
|
||
rpc.<svc> request/reply (rpc.indexer)
|
||
room.<grupo> chat humano/grupo (room.general)
|
||
agent.<nombre>.{in,out} inbox/outbox de agente LLM (agent.scout.in)
|
||
```
|
||
|
||
## Capability growth log
|
||
|
||
- v0.9.0 (2026-06-07) — cierre de los gaps que el despliegue del cluster (report
|
||
0011) dejó abiertos (report 0012). (GAP A) Nueva capability `membershipd user
|
||
add|list|revoke --store kv`: alta/baja de usuarios contra el KV replicado del
|
||
cluster EN MARCHA, sin el procedimiento de parar-sembrar-rearrancar. Usa la
|
||
conexión interna privilegiada — el daemon persiste su identidad de servicio con
|
||
`--internal-id-file` (cada nodo genera/carga la suya, 0600 junto a las claves TLS)
|
||
y la CLI, ejecutada por loopback en un nodo, presenta esa nkey que el
|
||
autenticador reconoce con permisos plenos de JetStream; ninguna identidad de
|
||
usuario normal puede tocar los buckets `KV_UNIBUS_*` bajo la ACL por-subject. El
|
||
alta es idempotente (re-alta de la misma clave = `ErrUserExists` explícito, sin
|
||
sobrescribir ni elevar rol), commitea con quórum 2/3 (HA, imprime
|
||
`followers_current`) y rechaza un destino remoto sin `--ca` (igual que
|
||
`migrate-to-kv`). (GAP B) Nuevo `cmd/clientcheck`: verificación end-to-end real
|
||
con un cliente autenticado (identidad operator, nkey+TLS+https) que crea una room
|
||
E2E, publica y recibe descifrado contra el cluster vivo, incluido un nodo parado a
|
||
media transmisión donde el cliente hace failover a un superviviente y sigue
|
||
recibiendo con cero pérdida (quórum 2/3) — el plano de datos que el chaos test del
|
||
0011 nunca probó. (GAP C) Runbook `deploy/cluster/README.md` corregido: el orden
|
||
de arranque "magnus solo y verifica healthz" deadlockeaba (un nodo solo no tiene
|
||
quórum del meta-group y nunca sirve healthz); se documenta el arranque por quórum,
|
||
que R1 es un SPOF inservible (ir directo a R3) y la nueva vía de alta con el
|
||
cluster vivo. La plantilla de deploy (unit + `deploy-cluster.sh`) emite ya
|
||
`INTERNAL_ID_FILE` y el flag. Verificado contra los 3 VPS reales (magnus + homer +
|
||
datardos); posture enforce+ACL+TLS+R3 intacta.
|
||
- v0.8.0 (2026-06-07) — completar y endurecer el cluster (issue 0006, fases
|
||
0006a–0006g) que cierra los bloqueantes de la auditoría dedicada del cluster
|
||
(report 0008) y cablea el control plane descentralizado que 0003 dejó a medias.
|
||
(0006a) Se cablea el nonce replicado en el binario: un nodo con `--cluster-name`
|
||
usa el bucket JetStream KV compartido obligatoriamente (fail-fast si no se crea),
|
||
cerrando el replay cross-node (N3); el "ciclo bootstrap" se resuelve con una
|
||
identidad interna efímera que el authenticator reconoce (full perms) y una
|
||
conexión in-process privilegiada. (0006b) Se cierra la fuga del control plane
|
||
por `$JS.API.>` (N2): la ACL pasa a un allow-set cerrado por-room (JS API solo de
|
||
los streams `UNIBUS_<room>` del peer), dejando `KV_UNIBUS_*`/`OBJ_*` fuera del
|
||
set y, por tanto, denegados. (0006c) Se cablea el store KV descentralizado
|
||
(`--store kv|sqlite`, default sqlite = baseline idéntico) con un `storeHolder`
|
||
fail-closed que rompe el ciclo bootstrap del authenticator. (0006d) Posture
|
||
homogénea: un nodo rechaza unirse al cluster sin `enforce`, y `/healthz` publica
|
||
la posture (N1). (0006e) Todos los clientes llaman `RefreshSession` tras cambios
|
||
de membresía (N4), de modo que la ACL es usable bajo enforce sin desactivarla.
|
||
(0006f) Bajos: secreto de cluster fuera de argv (`--cluster-pass-file`/env +
|
||
inyección en routes), `migrate-to-kv` rechaza target remoto sin `--ca`, y docs
|
||
de CA separada para routes + R1 SPOF vs R3 HA. (0006g) Material de deploy del
|
||
cluster de 3 nodos (magnus+homer+datardos) en `deploy/cluster/` (certs, unit,
|
||
script de despliegue dry-run, runbook) — sin tocar ningún VPS. Toda la
|
||
regresión de auditorías previas + los ataques 0008 siguen verdes; govulncheck 0
|
||
alcanzables. Branch-by-abstraction: con `--store sqlite` el single-node sigue
|
||
idéntico y desplegable en todo momento.
|
||
- v0.7.0 (2026-06-07) — hardening de seguridad 2 (issue 0005, fases 0005a–0005e)
|
||
que cierra los hallazgos nuevos de la re-auditoría red-team (report 0006) y
|
||
lleva el veredicto de exposición pública a "sí-con-condiciones". (0005a) Bump de
|
||
`github.com/nats-io/nats-server/v2` v2.10.22→v2.11.15 y de la toolchain a
|
||
go1.26.4: `govulncheck ./...` pasa de 16 vulnerabilidades alcanzables (14 del
|
||
servidor NATS embebido + 2 de la stdlib) a 0. (0005b) `client.processFrame`
|
||
ahora descarta cualquier frame sin firma en una room `SignMsgs` (antes verificaba
|
||
solo si la firma venía presente, lo que permitía suplantar `Sender` con
|
||
`Sig==nil`). (0005c) Nuevo limiter global de bytes en vuelo
|
||
(`pkg/membership.inflightLimiter`) que acota la memoria agregada que el control
|
||
plane bufferiza bajo concurrencia (el límite por-request y el rate-limit por-IP
|
||
no acotaban el total): un flood concurrente multi-IP se descarta con 503 en vez
|
||
de crecer sin techo (el RSS deja de escalar con N). (0005d) El guard de arranque
|
||
`validateBootConfig` ahora exige `--tls-cert/--tls-key` en bind no-loopback (un
|
||
control plane público sin TLS servía metadata en claro). (0005e) Se cablea por
|
||
fin en `membershipd` la ACL por subject que ya existía huérfana desde 0003e
|
||
(`busauth.NewNkeyAuthenticatorACL` + nuevo adaptador `busauth.PermissionsFromSubjects`
|
||
sobre `membership.SubjectACLFor`): un registrado no-miembro ya no puede
|
||
`Subscribe(">")` y captar los subjects/advisories de rooms ajenas. Residuales
|
||
documentados: `$JS.API.>` sigue compartido (cierre completo = NATS accounts por
|
||
identidad, diferido) y los clientes deben `RefreshSession` tras cambios de
|
||
membresía (chat/worker aún no lo hacen). El comportamiento de un solo nodo no
|
||
cambia y master sigue verde.
|
||
- v0.6.0 (2026-06-07) — descentralización / alta disponibilidad (issue 0003,
|
||
fases 0003a–0003e), report 0006. El servidor NATS embebido gana soporte de
|
||
cluster con routes autenticadas (secreto de cluster) y TLS mutuo de nodo
|
||
(`pkg/embeddednats.ClusterConfig` + `busauth.RouteTLSConfig`, reusando la CA
|
||
del 0001). El control plane (`pkg/membership.Store`) pasa a interfaz por
|
||
branch-by-abstraction: `sqliteStore` (default) + `jetstreamStore` nuevo sobre
|
||
JetStream KV replicado (réplicas configurables R1→R3), con `IsAuthorized`
|
||
fail-closed ante pérdida de quorum. `membershipd migrate-to-kv` mueve el
|
||
estado SQLite→KV de forma idempotente con backup previo. Los blobs
|
||
(`pkg/blobstore.Store`, ahora interfaz) ganan un backend NATS Object Store
|
||
replicado además del disco. El cliente acepta listas de seeds NATS y de
|
||
control planes con failover/reconnect nativo, el anti-replay pasa a un store
|
||
de nonces compartido en KV con TTL (cierra el agujero de replay multi-nodo), y
|
||
se implementa la ACL por subject derivada de pertenencia (audit H4 residual:
|
||
`busauth.NewNkeyAuthenticatorACL` + `membership.SubjectACLFor` +
|
||
`client.RefreshSession`). Todo viaja detrás del flag `decentralized` (off):
|
||
el comportamiento de un solo nodo (SQLite + disco) no cambia y master sigue
|
||
verde. El despliegue multi-nodo real (0003f) lo ejecuta el humano.
|
||
- v0.5.0 (2026-06-07) — hardening de seguridad (issue 0004) que cierra los
|
||
hallazgos de la auditoría red-team (report 0004) y lleva el veredicto de
|
||
exposición pública de "NO" a "sí-con-condiciones". Anti-DoS pre-auth
|
||
(`http.MaxBytesReader` por ruta + rechazo por `Content-Length` + rate-limit
|
||
por IP + `MaxHeaderBytes`); guard de fail-open que prohíbe arrancar con bind
|
||
público o TLS sin `--bus-auth enforce`; autorización por pertenencia en los GET
|
||
de room (metadata y clave sellada solo para miembros / el propio endpoint);
|
||
rooms cleartext deshabilitadas en bind público (contenido siempre E2E, mínimo
|
||
defensivo del data plane mientras la ACL por subject llega con 0003); TLS en el
|
||
control plane HTTP con la CA propia y cliente que exige `https` cuando hay CA;
|
||
y los medios H6/H7/H12 (owner ligado al firmante, `IsAuthorized` antes del
|
||
nonce-cache con poda O(expired) + cap, errores genéricos al cliente). Cada
|
||
hallazgo lleva su test adversarial `TestAudit_*` portado como regresión.
|
||
- v0.4.0 (2026-06-07) — descubrimiento de rooms: `GET /members/{endpoint}/rooms`
|
||
lista las rooms de un endpoint con su metadata y rol, y `client.ListMyRooms()`
|
||
lo consume. El control plane es pull (no hay push de invitaciones), así que un
|
||
peer recién invitado a una room cifrada la descubre por polling y luego hace
|
||
`Join` + `Subscribe`. Pieza base para que los bots de `agents_and_robots`
|
||
hablen por el bus en vez de Matrix (modelo "todo son rooms", E2E).
|
||
- v0.3.0 (2026-06-06) — `membershipd` se convierte en service de verdad: flag
|
||
`--bind` (default 127.0.0.1) que gobierna a la vez el HTTP de control y el NATS
|
||
embebido (`embeddednats.StartHost`), de modo que con `--bind 0.0.0.0` un
|
||
teléfono o PC de la LAN conecta a ambos planos. Se añade un systemd-user unit
|
||
(`deploy/unibus-membershipd.service`, `Restart=always`) + `deploy/install.sh`
|
||
idempotente, y el bloque `service:` queda completo (systemd-user, restart
|
||
always, health `/healthz`). El `Frame` (pkg/frame) gana threading aditivo
|
||
(`ThreadID`, `ReplyTo`) y un tipo `REACT`, con `PublishReply`/`React` en el
|
||
cliente — la base para que bots de chat hablen por el bus (fase 2). Cambios
|
||
100% aditivos: el wire de los frames no-threaded es idéntico y los tests
|
||
existentes siguen verdes.
|
||
- v0.2.0 (2026-06-03) — el playground gana un benchmark de rendimiento
|
||
(`GET /api/bench`, SSE): un publisher inunda una room con miles de mensajes a
|
||
N subscribers y una gráfica en vivo anima el throughput. Expone las dos
|
||
políticas como flags independientes (JetStream/`Persist` y encriptación
|
||
E2E/`Encrypt`) más tamaño de payload, de modo que se mide el coste de cada
|
||
capa (core NATS vs JetStream vs E2E vs E2E+JetStream) usando la librería
|
||
cliente real, sin reimplementar nada.
|