--- name: unibus lang: go domain: infra version: 0.11.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. - **Gestión de usuarios: storage unificado, alta por dos vías.** El allowlist de usuarios vive en el MISMO store que las rooms (`pkg/membership.Store`): SQLite en single-node, JetStream KV replicado (`UNIBUS_users`) en cluster. El `Server` ya tiene ese store privilegiado abierto (es quien sirve el KV en cada nodo), así que expone `GET/POST /users` y `POST /users/{signpub}/revoke` como API HTTP admin-only, simétrica con las rutas de rooms: el panel de administración firma como admin y el server ejecuta la mutación contra el mismo store. El panel NO necesita `--db`, ni la identidad interna, ni correr en un nodo del cluster; funciona idéntico en single-node y cluster. La autorización es default-deny: solo un firmante que el store confirma como `role == "admin"` activo pasa, cualquier otro recibe 403 (encima de la firma+nonce+TLS ya existentes). La CLI `membershipd user add --store kv` sigue existiendo SOLO para sembrar el admin #0 (bootstrap del huevo-gallina: 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. El alta es idempotente igual que la CLI: re-alta de una clave ya registrada = 409, sin sobrescribir ni elevar rol; el revoke es un flip de status (sin hard-delete), auditable. - **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.. telemetría/coordinación de procesos (proc.test.ticks) rpc. request/reply (rpc.indexer) room. chat humano/grupo (room.general) agent..{in,out} inbox/outbox de agente LLM (agent.scout.in) ``` ## Capability growth log - v0.11.0 (2026-06-07) — flag dedicado `UNIBUS_NATS_MONITOR` que abre el endpoint de monitoring HTTP del nats-server embebido (`127.0.0.1:8222`, loopback only) de forma DESACOPLADA del debug-log. Antes el monitoring solo se abría con `UNIBUS_NATS_DEBUG=1`, que además encendía el log verboso del nats-server (rutas/RAFT/subjects a journald en claro) — incompatible con el endurecimiento del issue 0007. El cómputo de los toggles se extrae a una función pura `natsLogOpts(debugEnv, monitorEnv) (noLog, debug, trace, monitor)`: `MONITOR=1` abre el endpoint dejando el log en silencio (`NoLog` true / `Debug` false), y se mantiene el acoplamiento inverso por compatibilidad (`DEBUG` sigue implicando `MONITOR`). El bind loopback `127.0.0.1` queda hardcoded — el monitoring NUNCA es público y no lleva auth; lo lee un scraper local que empuja a VictoriaMetrics (dashboard `unibus-nats` en `fleet_monitoring`). Se versiona el cableado de deploy: drop-in systemd aditivo `membershipd-cluster.service.d/nats-monitor.conf` (`Environment=UNIBUS_NATS_MONITOR=1`) + sección "NATS server metrics" en el README del cluster con el runbook de activación rolling (magnus→homer→datardos) y gate de reconvergencia R3 (`followers 2/2`) entre nodos. Tests nuevos: tabla pura del desacoplamiento (monitor on ⇒ log NO debug; debug ⇒ monitor; default cerrado) + server real con `MONITOR=1` que confirma `/varz` 200 en loopback:8222 y server sin flag con el endpoint cerrado. Cambios 100% aditivos: sin el flag el comportamiento es idéntico; build/test verdes. - v0.10.0 (2026-06-07) — API HTTP admin-only de gestión de usuarios, cerrando la última asimetría del control plane: las rooms tenían superficie HTTP firmada (`POST /rooms`, etc.) pero los users solo se gestionaban por CLI local o acceso directo al store. Se añaden `GET /users` (lista completa, incluidos revocados), `POST /users` (alta `{sign_pub, handle, role}`: valida hex de 64 chars + role en `{admin, member}`, 409 idempotente que no sobrescribe ni eleva rol) y `POST /users/{signpub}/revoke` (flip de status, sin hard-delete). Los tres pasan por un helper `requireAdmin` default-deny que confirma contra el store que el firmante autenticado es un user `role == "admin"` activo (el endpoint id es un hash one-way de la clave, así que el contexto lleva ahora también el `sign_pub` hex del firmante para resolver `GetUser`); cualquier otro firmante recibe 403, encima de la firma+nonce+TLS+ enforce ya heredadas del middleware. NO se abre conexión KV nueva ni se usa la identidad interna: el server escribe vía su `s.store` privilegiado, el MISMO que las rooms (SQLite single-node, KV `UNIBUS_users` en cluster). `pkg/client` gana `ListUsers/AddUser/RevokeUser` (tipo plano `UserInfo`) firmando como admin, así la pestaña Users del panel deja de necesitar `--db`/acceso KV directo. La CLI `membershipd user add --store kv` queda SOLO para sembrar el admin #0 (bootstrap). La validación de `sign_pub` se unifica en `membership.ValidateSignPubHex`, reusada por la CLI y los handlers. Tests nuevos: no-admin → 403 en los tres endpoints, roundtrip admin add→list→revoke, y validación (hex inválido → 400, role inválido → 400, re-alta → 409), más un test de cliente contra un membershipd embebido. Cambios 100% aditivos: el comportamiento single-node y de las rutas de rooms no cambia; vet/build/test verdes. - 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_` 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.