Seguridad en producción y runbook de operación
Seguridad en producción y runbook de operación
Último capítulo. Ya levantaste un relay, lo configuraste, lo desplegaste, le diste identidad, membresía, medios, búsqueda, multi-tenant, observabilidad y respaldos. Falta lo que separa un relay que funciona de un relay que puedes dejar encendido: entender qué defiende y qué no, endurecerlo antes de exponerlo, y tener un procedimiento escrito para cuando algo se rompa a las tres de la mañana.
Este capítulo se apoya en SECURITY.md y en la sección 7 de ARCHITECTURE.md (Security Model). Todo lo que aparece sale de ahí o del código.
1. El modelo de seguridad: qué defiende Buzz
ARCHITECTURE.md abre su sección 7 con la postura completa: “Toda operación sensible a seguridad usa un patrón explícito y verificado. No hay confianza implícita.” Eso se traduce en cinco capas, y el orden importa: si una falla, la siguiente no necesariamente te salva.
flowchart TD
A["Transporte: TLS terminado fuera del relay"] --> B["Admision: semaforo de conexiones y limite de frame"]
B --> C["Identidad: NIP-42 en WebSocket, NIP-98 en HTTP"]
C --> D["Autorizacion: membresia de canal como unica puerta"]
D --> E["Integridad: firma Schnorr y audit log encadenado"]
E --> F["Validacion de entrada: UUIDs, SSRF, inyeccion de DDL"]
1.1 Autenticación: dos rutas, ninguna opcional
| Concern | Mecanismo |
|---|---|
| Timestamp NIP-42 | Tolerancia de ±60 segundos — previene ataques de replay |
| Eventos AUTH | Nunca almacenados en Postgres, nunca registrados en la cadena de auditoría |
| NIP-98 HTTP Auth | Eventos kind:27235 firmados con Schnorr — verificación de URL y método |
Toda conexión WebSocket recibe ["AUTH", "<challenge>"] antes de poder escribir; el cliente responde con un kind:22242 firmado que contiene el challenge y la URL del relay. Los endpoints REST usan NIP-98: el cliente firma un kind:27235 con la URL y el método.
Un detalle que confunde: en el documento NIP-11 que sirve el relay, auth_required es siempre true (crates/buzz-relay/src/nip11.rs), porque los handlers de REQ, EVENT y COUNT rechazan incondicionalmente toda conexión que no esté en AuthState::Authenticated. Es independiente del toggle REST BUZZ_REQUIRE_AUTH_TOKEN. No existe un modo “relay sin autenticación” en el camino WebSocket. Repaso completo en Identidad y autenticación.
1.2 Autorización: membresía de canal, y nada más
SECURITY.md es tajante: “La membresía de canal es el único mecanismo de control de acceso. No hay listas ACL separadas ni taxonomías de capacidades.” Si un principal — humano o agente — es miembro de un canal, puede leer y escribir en él; si no lo es, el relay rechaza sus peticiones aunque esté autenticado. Los canales privados son invisibles para los no-miembros: no aparecen en listados y los filtros de suscripción devuelven vacío.
Tres garantías de implementación que explican por qué no hay agujeros de carrera:
- El handler REQ verifica el acceso antes de registrar la suscripción. No hay ventana entre registro y verificación por la que un no-miembro reciba eventos vivos de un canal privado.
- Las operaciones de membresía son TOCTOU-safe: toda secuencia de comprobar-y-modificar corre dentro de una transacción de Postgres.
- El fan-out excluye explícitamente las suscripciones globales (sin
channel_id) de los eventos channel-scoped, coincida o no el filtro. Es una frontera de seguridad deliberada.
1.3 Identidad en vez de flags de permisos
El principio que ordena todo lo demás está escrito en el README.md: “Deja que un agente triage un bug sin darle las llaves del reino. Los agentes tienen sus propias claves, sus propias membresías de canal y su propio rastro de auditoría. Scopeados por identidad, no por flags de permisos — igual que scopearías a un compañero de equipo.”
En la práctica no existe un panel de casillas de permisos. Le das al agente una clave Nostr propia, lo agregas como miembro de los canales donde debe trabajar, y eso es todo su alcance; lo que hace queda firmado por su clave, no por la tuya. Revocarle acceso es quitarlo de la membresía. El corolario operativo: tu superficie de gobierno es la membresía, que administras con buzz-admin y los comandos de Membresía, roles y moderación.
1.4 Validación de entrada, SSRF y webhooks
| Concern | Mecanismo |
|---|---|
| Firmas Schnorr | verify_event() en buzz-core — todo evento verificado antes de almacenar |
| Event ID | SHA-256 de la serialización canónica, verificado independiente de la firma |
| Tamaño de frame | BUZZ_MAX_FRAME_BYTES, default DEFAULT_MAX_FRAME_BYTES = 512 * 1024 (config.rs:14) — frames sobredimensionados rechazados, conexión cerrada |
| Event IDs en búsqueda | Validación de hex de 64 chars antes de construir la URL — previene path injection |
| Step IDs de workflow | Solo alfanuméricos y guion bajo — previene inyección de variables en evalexpr |
| Nombres de partición | Allowlist de tablas y validadores estrictos de sufijo/fecha — previene inyección de DDL |
La verificación de firma es CPU-bound, así que corre dentro de spawn_blocking. El límite de frame se aplica dos veces: limit_relay_websocket lo fija en el parser de tungstenite antes de ensamblar el mensaje (router.rs:314-326), y recv_loop mantiene el chequeo a nivel de aplicación como defensa en profundidad (connection.rs:445-475).
Una advertencia sobre el valor: la tabla de la sección 7 de ARCHITECTURE.md sigue diciendo MAX_FRAME_BYTES = 65,536, y eso ya no es lo que hace el binario. crates/buzz-relay/src/config.rs:14 define DEFAULT_MAX_FRAME_BYTES = 512 * 1024, es decir 524.288 bytes, con el comentario de que debe superar cómodamente el tamaño de contenido aceptado tras el JSON de Nostr y el overhead de cifrado NIP-44. Ese mismo valor es el que el relay publica como max_message_length en su documento NIP-11 (nip11.rs:101-108), así que la forma no ambigua de saber el límite real de tu despliegue es consultarlo:
curl -fsS -H 'Accept: application/nostr+json' https://buzz.midominio.cl/ | jq '.limitation.max_message_length'
is_private_ip() en crates/buzz-core/src/network.rs cubre, para protección SSRF: IPv4 unspecified (0.0.0.0/8), loopback (127.0.0.0/8), privadas (10/8, 172.16/12, 192.168/16), link-local (169.254/16), broadcast (255.255.255.255), CGNAT (100.64/10, RFC 6598 — riesgo real de metadata en AWS y GCP) y benchmarking (198.18/15, RFC 2544); IPv6 loopback (::1), unspecified (::), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8) y documentación (2001:db8::/32, RFC 3849).
Lo que suele faltar en implementaciones caseras y aquí sí está son los túneles y traducciones, todos comprobando recursivamente la IPv4 embebida: direcciones IPv6 compatibles y mapeadas vía to_ipv4(), el prefijo SIIT IPv4-translated ::ffff:0:0:0/96 —que to_ipv4() no reconoce—, NAT64 well-known 64:ff9b::/96 (RFC 6052) y su rango local-use 64:ff9b:1::/48 (RFC 8215), Teredo 2001::/32 (RFC 4380) y 6to4 2002::/16 (RFC 3056). Sin esos casos, http://[64:ff9b::a9fe:a9fe]/ sería una ruta abierta al servicio de metadata. Se aplica en la acción CallWebhook de buzz-workflow.
Sobre webhooks: los entrantes (POST /hooks/{id}) usan comparación de tiempo constante con XOR contra un secreto UUID almacenado — no es HMAC, compara el secreto directamente, no un MAC del cuerpo. Los salientes tienen SSRF, redirects deshabilitados y tope de 1 MiB en la respuesta. Los tokens de aprobación son UUID de CSPRNG, almacenados como hash SHA-256, single-use forzado con AND status = 'pending' en el UPDATE.
1.5 Integridad del audit log
El SHA-256 de cada entrada cubre todos los campos incluido prev_hash, así que manipular una fila rompe todos los hashes posteriores. El JSON es canónico (BTreeMap para orden determinista de claves), la escritura se serializa con pg_advisory_lock, y catch_unwind garantiza la liberación del lock incluso ante un panic.
Ahora la advertencia importante, textual de SECURITY.md: “Como la cadena es sin clave, es tamper-evident pero no tamper-resistant: detecta corrupción accidental o la edición de una fila suelta, pero un atacante con acceso de escritura a la base de datos puede recomputar la cadena entera después de editar.” Traducción operativa: te protege del error y de la manipulación torpe, no de un adversario con credenciales de Postgres. La defensa real es no repartir esas credenciales.
1.6 TLS: el relay no lo hace, y es a propósito
SECURITY.md: “Todos los despliegues de producción deberían terminar TLS en el relay o en un proxy inverso delante de él. El relay en sí no fuerza TLS — esto es intencional para permitir despliegues flexibles detrás de balanceadores de carga y controladores de ingress.”
En el bundle de Compose lo resuelve el override de Caddy. El Caddyfile completo son cinco líneas: {$BUZZ_DOMAIN} { encode zstd gzip; reverse_proxy relay:3000 }. Y compose.caddy.yml hace ports: !reset [] sobre el servicio relay, es decir, deja de publicar el puerto directo al host. Ese detalle es la mitad de la seguridad de transporte: sin él tendrías TLS en el 443 y un 3000 en claro al lado.
cd deploy/compose
BUZZ_COMPOSE_TLS=true ./run.sh start
Requiere Docker Compose 2.24.4 o superior, porque el tag !reset no existe antes.
1.7 Gestión de secretos
Del lado servidor son cinco, y todos deben ser estables entre reinicios:
| Secreto | Variable | Qué pasa si lo rotas |
|---|---|---|
| Clave de firma del relay | BUZZ_RELAY_PRIVATE_KEY | El relay cambia de identidad; los eventos NIP-43 firmados antes dejan de verificar |
| Secreto HMAC de hooks git | BUZZ_GIT_HOOK_HMAC_SECRET | Los hooks git dejan de validar. Falla el arranque si mide menos de 32 caracteres |
| Password de Postgres | POSTGRES_PASSWORD | Obligatoria; el compose aborta sin ella |
| Password de Redis | REDIS_PASSWORD | Obligatoria |
| Credenciales S3 | BUZZ_S3_ACCESS_KEY / BUZZ_S3_SECRET_KEY | Obligatorias en producción |
El relay se niega a arrancar en varios de estos casos, y esa negativa es una feature. En crates/buzz-relay/src/main.rs:
if config.require_relay_membership && config.relay_private_key.is_none() {
return Err(anyhow::anyhow!(
"BUZZ_RELAY_PRIVATE_KEY is required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true. \
NIP-43 events signed with an ephemeral key become unverifiable after restart."
));
}
Del lado del cliente de escritorio, las claves nsec van al keyring del sistema operativo (Keychain en macOS, Credential Manager en Windows, Secret Service vía D-Bus en Linux), tanto la identidad humana como la de cada agente gestionado. La migración desde texto plano importa la clave, la lee de vuelta para verificar el round-trip y solo entonces borra el plano; si el keyring no está disponible esa sesión, no migra, así una caída transitoria no puede resucitar una clave rotada desde un archivo residual. Sin backend de keyring (Linux headless), cae a un archivo 0o600. La variable BUZZ_PRIVATE_KEY, cuando está definida, tiene precedencia sobre ambos almacenes: así es como CI y los agentes harneseados reciben su identidad.
1.8 Dependencias y código inseguro
SECURITY.md declara que #![deny(unsafe_code)] está aplicado “across all crates”. Matiz que conviene tener: el atributo está presente en los crates del núcleo y del servidor —buzz-core, buzz-db, buzz-auth, buzz-pubsub, buzz-search, buzz-audit, buzz-workflow, buzz-relay, buzz-admin, buzz-conformance, buzz-sdk, buzz-acp, buzz-dev-mcp, buzz-ws-client, buzz-test-client—, no en los 28 miembros del workspace, y no hay [workspace.lints] que lo imponga de forma global. La afirmación es cierta para el camino de datos del relay; verifica el crate concreto si tu auditoría necesita la garantía completa.
Sobre dependencias hay otra discrepancia parecida: SECURITY.md:116 menciona cargo audit en CI, pero el workflow real (.github/workflows/ci.yml:888-900) ejecuta el job security con cargo-deny check como política de dependencias. Ese es el comando que corre de verdad.
2. Lo que el modelo NO cubre
ARCHITECTURE.md mantiene una sección 9 de limitaciones conocidas, con la advertencia de que “son huecos verificados de la implementación actual, no aspiraciones de diseño”. Las relevantes:
- Sin caché offline de consultas sqlx: se usa
sqlx::query()en runtime, no la macro de compile-time. No hay directorio.sqlx/; las consultas no se validan al compilar. - Compuertas de aprobación no cableadas de extremo a extremo: el ejecutor devuelve
StepResult::Suspendedy el relay tiene endpoints de grant/deny con CRUD, pero el motor intercepta antes de crear filasWaitingApproval; los runs que llegan a una compuerta se marcan como Failed. - Acciones de workflow parcialmente stub:
send_dmyset_channel_topicestán en el esquema pero devuelvenNotImplemented.
Sobre rate limiting, la sección 9 afirma que no hay implementación. El código contradice esa afirmación: existe RedisRateLimiter en crates/buzz-pubsub/src/rate_limiter.rs, con un script Lua atómico (INCR + EXPIRE) implementando el trait RateLimiter de buzz-auth, y el relay lo usa en admission::check_principal. El documento está desactualizado; el código manda. Lo que sí sigue siendo cierto es la advertencia del propio limitador: son ventanas fijas, que permiten hasta 2× de ráfaga en los bordes.
Del README, el reparto de madurez: la columna 💭 “Strong opinions, pending code” (reputación web-of-trust entre relays, notificaciones push, features de cultura) viene con un pie de página explícito — “por favor no planifiques tu programa de cumplimiento alrededor de la columna 💭 todavía”. Las compuertas de aprobación de workflows y el ciclo de vida de huddle están en la columna 🚧 “Being wired up”. No los cuentes como controles.
3. Checklist de endurecimiento antes de abrir el relay al mundo
Los defaults del binario son de desarrollo; los del deploy/compose/.env.example son de producción. Esa diferencia es la fuente de la mayoría de despliegues inseguros: si copias tu .env de desarrollo al VPS, arrancas con el relay abierto.
3.1 Identidad y política
BUZZ_REQUIRE_AUTH_TOKEN=true # default del binario: false
BUZZ_REQUIRE_RELAY_MEMBERSHIP=true # default del binario: false
RELAY_OWNER_PUBKEY=<64 hex> # sin prefijo BUZZ_, a proposito
BUZZ_RELAY_PRIVATE_KEY=<64 hex> # estable entre reinicios
-
BUZZ_REQUIRE_AUTH_TOKEN=trueyBUZZ_REQUIRE_RELAY_MEMBERSHIP=truesi el relay es cerrado. -
RELAY_OWNER_PUBKEYfijado a hex válido de 64 caracteres: el relay aborta el arranque si exiges membresía sin dueño configurado. -
BUZZ_RELAY_PRIVATE_KEYgenerada, respaldada y estable. Sin ella, conBUZZ_REQUIRE_AUTH_TOKEN=true, el proceso hacepanic!al arrancar. - Ningún valor
CHANGE_MErestante.run.shlo verifica conrequire_env()antes destart,restart,pull,upgradeyconfig— pero no antes destop,logs,statusni los subcomandos de miembros.
3.2 Superficie de red
- TLS terminado delante del relay (
BUZZ_COMPOSE_TLS=truecon Caddy, o tu ingress). - Puerto 3000 no publicado al host cuando hay proxy; el override de Caddy lo hace solo con
ports: !reset []. - Puertos
8080(health) y9102(metrics) no expuestos públicamente. En el bundle de producción se fijan por env pero no se publican al host. -
BUZZ_CORS_ORIGINScon la lista exacta de orígenes; el default del binario es vacío. -
RELAY_URLapuntando a tu URL públicawss://real: es la que se usa en los desafíos NIP-42 y un valor equivocado rompe la autenticación. - Postgres, Redis y MinIO sin puertos publicados. En el bundle solo aparecen si activas
compose.dev.yml; no lo actives en producción. Adminer y Prometheus viven ahí también: fuera.
3.3 Límites de tamaño y de tasa
| Límite | Variable | Default |
|---|---|---|
| Conexiones WebSocket | BUZZ_MAX_CONNECTIONS | 10000 |
| Handlers concurrentes | BUZZ_MAX_CONCURRENT_HANDLERS | 1024 |
| Buffer de envío por socket | BUZZ_SEND_BUFFER | 1000 |
| Frame WebSocket | BUZZ_MAX_FRAME_BYTES | 524.288 bytes (512 KiB) |
| Gracia de cliente lento | BUZZ_SLOW_CLIENT_GRACE_LIMIT | 15 |
| Imagen / GIF / video | BUZZ_MAX_IMAGE_BYTES / BUZZ_MAX_GIF_BYTES / BUZZ_MAX_VIDEO_BYTES | 50 MiB / 10 MiB / 500 MiB |
| Pack y repo git | BUZZ_GIT_MAX_PACK_BYTES / BUZZ_GIT_MAX_REPO_BYTES | 500 MiB / ~1000 MiB |
| Repos por pubkey | BUZZ_GIT_MAX_REPOS_PER_PUBKEY | 100 |
| Subidas concurrentes por proceso / por pubkey | BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS / _PER_PUBKEY | 8 / 2 |
| Subidas por minuto | BUZZ_MEDIA_UPLOADS_PER_MINUTE | 30 |
Y los tiers de rate limit compartido en Redis:
BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60
BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300
BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10
BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120
BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN=600
BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN=300
BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN=600
- Tiers de agente revisados: un agente tiene el doble de presupuesto que un humano por defecto. Si corres flotas grandes, esa es tu palanca.
- Presupuesto de ráfaga WS entendido:
admission.rsusa ventana fija de 5 segundos con límiteper_second_limit * 5, porque el arranque del escritorio abre varias suscripciones a la vez. - Comportamiento fail-closed asumido: si el store de admisión compartido no responde, el relay rechaza con
"rate-limited: shared admission unavailable". Redis caído no es “sin límites”, es “sin servicio”.
3.4 Datos, imagen y multi-tenant
-
BUZZ_AUTO_MIGRATEdecidido conscientemente:true, o correrbuzz-admin migrateantes del arranque. Es opt-in en el binario. - Bucket S3/MinIO sin acceso anónimo:
minio-initlo crea conmc anonymous set none; si usas un proveedor externo, replícalo.BUZZ_S3_ADDRESSING_STYLEcorrecto (pathovirtual; cualquier otro valor es fallo de arranque). - Respaldos verificados según el capítulo 13, incluida la clave privada del relay.
-
BUZZ_IMAGEpinneada aghcr.io/block/buzz:sha-<7>y no a:main— el default apunta amain, pensado para pruebas tempranas. - Feature
devno habilitada. La derivación de clave de desarrollo (SHA-256("buzz-test-key:{username}")) vive tras#[cfg(any(test, feature = "dev"))]yARCHITECTURE.mdes explícito: “la featuredevno debe habilitarse en despliegues de relay de producción”. -
BUZZ_HUDDLE_AUDIO_AVAILABLE=falsecon más de una réplica: el default del binario estruey el audio de huddle solo es seguro en despliegue de un pod hasta que exista un SFU. -
BUZZ_MESHsin activar salvo que lo quieras: es opt-in y un upgrade de imagen sin tocar env no abre el puerto UDP3478. -
RUST_LOGen niveles de producción (buzz_relay=info,...), no endebug. - Con varias comunidades, revisa Multi-tenant: el binding ocurre en el paso 0 de la conexión, desde el host, antes de que ningún handler observe datos de tenant, y un host desconocido rechaza genéricamente sin caer a un tenant por defecto.
- Con
replicaCount > 1, Redis deja de ser opcional: el anti-replay NIP-98 se apoya enRedisNip98ReplayGuard(SET buzz:{community}:nip98:{id} NX EX, piso de 120 s) y el chart falla el render si subes réplicas sinredis.enabled=true,externalRedis.urlo unREDIS_URLensecrets.existingSecret. Ver Multi-tenant.
4. Runbook: síntoma → diagnóstico → acción
Todo runbook se usa bajo estrés, así que la forma importa: primero el síntoma tal como lo ves, después el comando que confirma, después la acción.
flowchart LR
S["Sintoma observado"] --> D1{"El proceso esta vivo?"}
D1 -->|No| A1["Logs de arranque, seccion 4.1"]
D1 -->|Si| D2{"Readiness responde 200?"}
D2 -->|No| A2["Leer el JSON: postgres o redis, secciones 4.2 y 4.3"]
D2 -->|Si| D3{"Los clientes autentican?"}
D3 -->|No| A3["Seccion 4.5"]
D3 -->|Si| D4{"Llegan los eventos?"}
D4 -->|No| A4["Seccion 4.6"]
D4 -->|Si| A5["Problema de cliente o agente, seccion 4.7"]
Los tres endpoints de diagnóstico están en el puerto de salud (BUZZ_HEALTH_PORT, 8080 por defecto):
curl -fsS http://127.0.0.1:8080/_liveness # "ok" si el proceso responde
curl -sS http://127.0.0.1:8080/_readiness # JSON con estado de Postgres y Redis
curl -sS http://127.0.0.1:8080/_status # service, version, uptime_seconds
readiness_handler hace ping a Postgres y pide una conexión al pool de Redis en paralelo, con timeout de 2 segundos. Si algo falla devuelve 503 con {"status":"not_ready","postgres":<bool>,"redis":<bool>}: esos dos booleanos te dicen cuál cayó sin entrar a ningún contenedor.
4.1 El relay no arranca
Síntoma. El contenedor entra en bucle de reinicio, o ./run.sh start nunca termina porque --wait espera un health que no llega.
Diagnóstico. cd deploy/compose && ./run.sh logs relay | head -50. Los fallos de arranque son deliberadamente ruidosos:
| Mensaje en el log | Causa | Acción |
|---|---|---|
RELAY_OWNER_PUBKEY required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true | Membresía exigida sin dueño, o hex inválido | Fija RELAY_OWNER_PUBKEY a 64 chars hex válidos |
BUZZ_RELAY_PRIVATE_KEY is required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true | Falta la clave de firma estable | Genera una con buzz-admin generate-key y guárdala en .env |
BUZZ_RELAY_PRIVATE_KEY must be set when BUZZ_REQUIRE_AUTH_TOKEN=true (panic) | Igual, por la vía del toggle REST | Misma acción |
git conformance probe failed: ... | La sonda A3 contra S3 no pasó | Ver abajo |
invalid media config: ... | BUZZ_S3_ADDRESSING_STYLE con valor distinto de path/virtual, u otro campo inválido | Corrige el valor |
Sobre la sonda A3: BUZZ_GIT_CONFORMANCE_PROBE está activa por defecto (cualquier valor distinto de "false" la enciende) y corre antes de abrir el listener. Escribe con BUZZ_GIT_PROBE_WRITERS escritores concurrentes (32 por defecto) durante BUZZ_GIT_PROBE_ROUNDS rondas (3), y el fallo es fatal porque un backend que no puede satisfacer CAS de punteros invalida el modelo de git sobre object storage. Si falla, tu proveedor S3 no soporta escritura condicional o las credenciales no tienen permiso. No la desactives para salir del paso en un relay que va a servir git: apagarla no arregla el backend, solo apaga la alarma.
Verificación tras corregir: ./run.sh config (renderiza y valida la config combinada), luego ./run.sh start y curl -fsS "http://127.0.0.1:${BUZZ_HTTP_PORT:-3000}/_liveness".
4.2 Postgres saturado
Síntoma. Latencia alta en todo, /_readiness intermitente en 503 con "postgres": false, timeouts al enviar eventos.
Diagnóstico.
curl -s http://127.0.0.1:9102/metrics | grep -E 'buzz_db_(read_)?pool_(size|active|idle|max)'
curl -s http://127.0.0.1:9102/metrics | grep -E 'buzz_db_replica_(fence_open|fence_lag_seconds|heartbeat_age_seconds)'
Si buzz_db_pool_active está pegado a buzz_db_pool_max, el pool es el cuello de botella. Complementa con buzz_event_processing_seconds y buzz_events_stored_total.
Acción. Sube BUZZ_DB_POOL_SIZE (default 50) si Postgres tiene margen de max_connections, recordando que el pool es por proceso: con tres réplicas multiplicas por tres. Configura READ_DATABASE_URL para mandar lecturas a una réplica; sin definir, todo va al writer. Revisa el particionado: events y delivery_log están particionadas mensualmente por rango sobre created_at, y una partición que no se creó a tiempo se nota como degradación súbita a fin de mes. Si es carga real y no configuración, escala horizontalmente el relay — pero replicaCount > 1 te obliga a BUZZ_GIT_HOOK_HMAC_SECRET y a BUZZ_HUDDLE_AUDIO_AVAILABLE=false.
4.3 Redis caído
Síntoma. Todo el mundo rechazado con "rate-limited: shared admission unavailable". Los mensajes no se propagan entre nodos. Presencia y typing congelados. /_readiness devuelve "redis": false.
Diagnóstico.
docker compose --env-file .env logs redis | tail -30
curl -s http://127.0.0.1:9102/metrics | grep -E 'buzz_redis_pool_(size|max|available|waiting)'
curl -s http://127.0.0.1:9102/metrics | grep 'buzz_admission_rejections_total'
buzz_admission_rejections_total lleva las etiquetas transport y reason=quota|unavailable. Si ves reason="unavailable" disparado, es Redis, no cuota.
Acción. Comprueba que el password coincide: en producción REDIS_URL se compone como redis://:${REDIS_PASSWORD}@redis:6379, y un password rotado a medias produce exactamente este síntoma. Redis en el bundle corre con --appendonly yes; si el volumen buzz-redis-data se llenó, rechaza escrituras. Recuerda que el subscriber usa una conexión PubSub dedicada, fuera del pool, con backoff exponencial de 1 s a 30 s: tras recuperar Redis la reconexión es automática, no reinicies el relay por reflejo. Vigila buzz_multinode_fanout_lag_total y buzz_cache_invalidation_lag_total durante la recuperación.
No hagas esto: no desactives el rate limiting para destrabar usuarios mientras Redis está caído. El fail-closed es intencional, y con Redis caído el fan-out entre nodos tampoco funciona: los usuarios entrarían a un relay que acepta eventos y no los entrega.
4.4 Los medios no suben
Síntoma. PUT /media/upload devuelve error; las imágenes quedan a medias en el cliente.
Diagnóstico. curl -s http://127.0.0.1:9102/metrics | grep -E 'buzz_media_(uploads_total|upload_rejections_total)'. Las etiquetas de rechazo que emite crates/buzz-relay/src/api/media.rs son reason="rate_limit" y reason="concurrency", y eso separa dos causas distintas:
| Etiqueta | Causa | Palanca |
|---|---|---|
rate_limit | Se superó BUZZ_MEDIA_UPLOADS_PER_MINUTE (30) | Subir el valor, o mirar quién sube tanto |
concurrency | Tope de BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS (8) o _PER_PUBKEY (2) | Subir el tope por proceso o por pubkey |
Si no hay rechazos y el error viene de más abajo, mira docker compose --env-file .env logs minio | tail -30.
Acción. En orden: (1) autenticación — GET/HEAD /media/* siempre exigen auth Blossom con t=get y membresía; BUZZ_REQUIRE_MEDIA_GET_AUTH y BUZZ_REQUIRE_MEDIA_READ_AUTH ya no se leen, definirlas no cambia nada y el relay lo avisa al arrancar. (2) Límites de tamaño contra BUZZ_MAX_IMAGE_BYTES, BUZZ_MAX_GIF_BYTES, BUZZ_MAX_VIDEO_BYTES. (3) Addressing style: path contra un proveedor que exige virtual produce errores de bucket no encontrado; en el bundle de Compose el endpoint está fijado a http://minio:9000 con estilo path y no es configurable por .env, así que para un proveedor externo necesitas Helm o un Compose propio. (4) URL pública: BUZZ_MEDIA_BASE_URL y BUZZ_MEDIA_SERVER_DOMAIN deben apuntar a tu dominio real; un upload que funciona y una descarga que no suele ser esto. Detalle en Medios y almacenamiento.
4.5 Los clientes no autentican
Síntoma. El cliente conecta, recibe el challenge y queda rechazado con auth-required: ... o restricted: ....
Diagnóstico.
curl -s http://127.0.0.1:9102/metrics | grep -E 'buzz_auth_(attempts_total|failures_total)|buzz_ws_auth_timeouts_total'
curl -s http://127.0.0.1:3000/info | jq '.limitation'
date # el desfase de reloj es causa real
Acción según la causa.
-
RELAY_URLequivocada. Es la causa número uno. Elkind:22242que firma el cliente contiene la URL del relay; si el relay esperawss://buzz.example.comy el cliente firmó contra otra cosa, la verificación falla. Detrás de un proxy,RELAY_URLdebe ser la URL pública, no la interna. -
Desfase de reloj. La tolerancia NIP-42 es de ±60 segundos. Un servidor sin NTP rechaza a todo el mundo de golpe y de forma inexplicable.
-
Allowlist activa. Con
BUZZ_PUBKEY_ALLOWLIST=true, las conexiones NIP-42 que autentican solo con pubkey se verifican contra la tablapubkey_allowlist. El fallo devuelve el genéricoauth-required: verification failed, sin mensaje específico de allowlist, así que hay que ir a mirar la tabla. No hay comando CLI todavía, y la allowlist debe activarse antes del arranque: no es un toggle caliente.SELECT encode(pubkey, 'hex'), added_at, note FROM pubkey_allowlist; INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex')); -
Membresía exigida. Con
BUZZ_REQUIRE_RELAY_MEMBERSHIP=true, autenticar no basta: hay que estar en el roster. Usa./run.sh list-membersy./run.sh add-member npub1... --role member. Al agregar varios seguidos ponsleep 1entre invocaciones: el evento de rosterkind:13534colisiona si dos escrituras caen en el mismo segundo. Nunca en paralelo conxargs -P. -
Agentes con NIP-OA. Si tus agentes autentican como delegados de su dueño, necesitas
BUZZ_ALLOW_NIP_OA_AUTH=true. Ojo: si el evento AUTH lleva más de un tagauth, se trata como si no llevara ninguno.
4.6 Los eventos no llegan
Síntoma. El emisor recibe ["OK", <id>, true, ""] pero el receptor nunca ve el mensaje. Hay seis causas estructurales que producen el mismo síntoma, y ninguna es un bug.
- Filtro sin
kinds. Las consultas deben especificarkinds; omitirlo dispara el p-gate (403). Y unkinds: []explícito significa “no coincidir con nada”, no comodín: esas suscripciones no se indexan en ningún tier y nunca reciben nada. Unkindsausente sí es comodín. La diferencia entre “ausente” y “arreglo vacío” ha costado muchas horas. - Suscripción global contra evento de canal. El fan-out excluye deliberadamente las suscripciones globales de los eventos channel-scoped. Si te suscribiste sin
channel_idy esperas mensajes de un canal privado, no llegarán nunca. - Evento efímero. Los kinds 20000–29999 no se almacenan y nunca aparecen en consultas históricas REQ. Presencia (20001) además salta las verificaciones de membresía y usa fan-out solo local, sin PUBLISH a Redis: en multi-nodo la presencia no cruza de pod.
- Cliente lento desconectado.
ConnectionState::send()usatry_send; tras varios eventos consecutivos con el buffer lleno la conexión se cancela. Mirabuzz_ws_backpressure_disconnects_totalybuzz_ws_send_batch_size. - Fan-out entre nodos roto. En multi-réplica, revisa
buzz_multinode_fanout_totalybuzz_multinode_fanout_lag_total. Si el primero no sube, el problema es Redis (§4.3). - Tope de historial. Una consulta histórica devuelve como máximo 500 resultados por filtro, tope duro. Lo que falta no está perdido: hay que paginar.
Acción. Confirma cuál de las seis es con un cliente mínimo antes de tocar configuración. El binario buzz-test-cli acepta --send, --subscribe, --channel, --url y --kind, y reproduce el caso sin la app de escritorio en medio.
4.7 Fallos silenciosos de welcome y kickoff
El repositorio le dedica un documento entero: docs/welcome-kickoff-silent-failures.md. Es la coreografía del canal Welcome, donde el agente Fizz publica un mensaje inicial, los compañeros se presentan en el hilo y Fizz publica un cierre. La causa raíz, textual: “El kickoff decide qué decir a partir de un temporizador y de la ausencia de evidencia, y luego escribe esa suposición con tinta permanente.” La distinción que faltaba: “el agente crasheó” es un hecho; “todavía no hay presentación” no lo es, es ignorancia. Anunciar ignorancia con fecha de vencimiento produce historias falsas.
| Clase | Fallo | Estado |
|---|---|---|
| Historia equivocada | El equipo se anuncia como lento o roto mientras funciona bien | Abierto |
| Demasiado ruido | Los agentes se responden entre sí indefinidamente | Corregido 2026-07-18 |
| Demasiado silencio | Nadie habla; el usuario mira un canal vacío | Abierto |
Síntoma A: el canal anuncia que los compañeros van lentos, y aparecen justo después. El temporizador TEAMMATE_INTRO_WAIT_MS valía 15 s mientras su vecino TEAMMATE_READY_WAIT_MS daba 60 s solo para arrancar un proceso; en observación real las presentaciones tardaron ~60 s. El cierre se publicaba con un marcador terminal: todo paso posterior hace early-return sobre él, así que la historia falsa nunca se corregía. La corrección publicada es la constante: TEAMMATE_INTRO_WAIT_MS = 15_000 pasó a TEAMMATE_INTRO_BACKSTOP_MS = 120_000, renombrada porque no es una expectativa de velocidad sino un backstop de rendición. Si operas una versión anterior, es lo primero que debes verificar; como no bloquea el camino feliz, subirla no cuesta nada en el caso normal.
Síntoma B: los agentes se responden en bucle sin decir nada. Dos reglas de crates/buzz-acp/src/base_prompt.md componían una máquina de movimiento perpetuo: “todo turno que procesa un mensaje de usuario DEBE publicar una respuesta” y “cuando terminas trabajo delegado, DEBES mencionar al delegador”. En una mención mutua el circuito se cierra y no vuelve a abrirse. El contenido era la pista: todos los agentes intentaban terminar la conversación, y anunciarlo era lo que la mantenía viva. La corrección acotó ambas reglas por qué tiene que decir el turno, no por quién lo disparó: publicar es obligatorio solo si el turno produjo algo que valga la pena saber, el silencio es explícitamente un éxito, y quedan prohibidos los acuses vacíos (“Got it”, “Confirmed”, “Standing by”, “Parked”), con el remate — si sientes la tentación de anunciar que dejas de responder, ese es el mensaje que no debes enviar.
Lo que sigue abierto: no hay contador de profundidad, ni límite de saltos, ni cooldown, ni presupuesto agente-a-agente en ninguna parte del camino. Las guardas existentes no sirven: ignore_self solo bloquea auto-respuestas y A→B→A es justo lo que se le escapa; la compuerta de autor admite hermanos por diseño (verifica agentes del mismo dueño vía NIP-OA); max_turns_per_session viene en 0 por defecto y es rotación de sesión, no freno de respuestas; los topes de cola son contrapresión sobre eventos pendientes, y un ping-pong nunca se acumula. Lo único que funciona hoy contra un equipo desbocado es Stop en la UI de Agents. Y !cancel es inalcanzable desde toda superficie de producto: el matcher exige kind:9, content.trim() == "!cancel" exacto y un tag p nombrando al agente, pero cada superficie deriva ese tag p del texto @Nombre del contenido — así que @Fizz !cancel falla el match exacto y un !cancel pelado no produce tag p. Solo un evento firmado a mano por POST /events los dispara.
Síntoma C: el canal Welcome queda vacío y nadie explica nada. Todo mensaje de fallback asume que Fizz está viva y puede publicar. Cuando Fizz es lo que falló, nadie habla. Los casos que el usuario no puede ver: Fizz no arranca (binario del harness ausente, error de spawn) y solo ella manda el opener; cualquier paso lanza excepción y todo el kickoff es un try/catch que registra y se rinde — visto en la práctica con relay inalcanzable, websocket caído, fallo de ensureWelcomeTeam, o el propio envío rechazado por rate limiting; y los fallos del cierre se capturan y registran, dejando el hilo sin CTA.
Lo que sí está resuelto es la percepción: tras 90 s sin mensajes el escenario del composer se retira y el banner cae a su indicación normal de mención, así que un kickoff fallido degrada a un canal vacío usable en vez de afirmar que el equipo sigue montándose. Pero el escenario solo lee “¿está vacía la línea de tiempo?” más ese temporizador, así que no distingue “Fizz crashó” de “el relay va lento”: otro cronómetro haciendo de hecho.
Acción del operador: cuando reporten un Welcome mudo, no lo diagnostiques desde el canal. Revisa el rate limiting del relay contra el agente (grep 'buzz_admission_rejections_total' en /metrics), que el relay acepte conexiones (/_readiness) y los logs del proceso del agente en la máquina del usuario. Hay un incidente documentado que conviene conocer: un agente de Welcome produjo 42 KB de log de reintentos “rate-limited: quota exceeded” en segundos contra un relay remoto. Un bucle de reintentos apretado contra una cuota hace fallar todos los demás envíos de la sesión, incluido el del kickoff. Si ves ese patrón, el problema no es el kickoff: es el backoff de publicación de buzz-acp.
4.8 Tabla resumen del runbook
| Síntoma | Primer comando | Causa más frecuente |
|---|---|---|
| El relay no arranca | ./run.sh logs relay | head -50 | RELAY_OWNER_PUBKEY o BUZZ_RELAY_PRIVATE_KEY ausentes |
| Todo lento, readiness intermitente | grep buzz_db_pool_active en /metrics | Pool de Postgres agotado |
rate-limited: shared admission unavailable | grep buzz_admission_rejections_total | Redis caído o password rotado a medias |
| Medios no suben | grep buzz_media_upload_rejections_total | Cuota por minuto o concurrencia por pubkey |
| Clientes no autentican | curl /info y date | RELAY_URL equivocada o reloj desfasado |
| Eventos que no llegan | Cliente de prueba con --kind | Filtro sin kinds, o sub global contra canal |
| Welcome mudo | grep buzz_admission_rejections_total | Bucle de reintentos del agente contra la cuota |
Para las métricas y tableros que sostienen todo esto, vuelve a Observabilidad.
5. Política de reporte de vulnerabilidades
Si encuentras un fallo de seguridad en Buzz — el software, no tu despliegue — SECURITY.md fija el procedimiento. No lo reportes por issues públicos de GitHub. Envía correo a [email protected] con: descripción de la vulnerabilidad y su impacto potencial, pasos para reproducir o prueba de concepto si la tienes, versiones o rango de commits afectados, y cualquier mitigación que hayas identificado.
Compromisos del proyecto: acuse de recibo en 48 horas y respuesta completa, incluido plazo de arreglo, en 7 días desde el contacto inicial. A cambio se pide dar tiempo razonable antes de cualquier divulgación pública, no acceder ni modificar datos ajenos, y no hacer ataques de denegación de servicio ni interrumpir sistemas de producción. Se acredita a quien reporta en las notas de versión salvo que prefiera anonimato, y se sigue divulgación coordinada: una vez publicado el arreglo se publica un advisory en GitHub con la vulnerabilidad, su impacto y la corrección.
Sobre versiones soportadas, Buzz es pre-1.0 y no mantiene ramas de soporte de largo plazo: main es soporte activo y los releases anteriores son mejor esfuerzo con recomendación de actualizar. Todos los arreglos de seguridad aterrizan primero en main. Consecuencia operativa directa: tu política de actualización es parte de tu política de seguridad. Pinnear a un sha-<7> y quedarte ahí un año no es prudencia, es deuda. Ver Backups, migraciones y actualizaciones.
6. Cierre del curso
6.1 Qué dominas ahora
Puedes explicar qué es Buzz y por qué un relay propio cambia la propiedad de los datos; compilar y correr un relay desde el código fuente con su Postgres, Redis y MinIO; configurarlo sabiendo que la diferencia entre los defaults del .env.example y los reales del binario es justo donde viven los errores de seguridad; desplegarlo en un VPS con Compose y TLS de Caddy, o en Kubernetes con Helm; gobernar la identidad con NIP-42, NIP-98, allowlist de pubkeys y delegación NIP-OA; administrar la comunidad con roster NIP-43, roles y moderación; operar medios y git sobre object storage con sus límites y su sonda de conformidad; buscar y auditar con FTS de Postgres sobre search_tsv y la cadena de hash por comunidad; correr varias comunidades sobre la misma infraestructura con binding por host; observar con métricas Prometheus, probes de salud y trazas OTLP; respaldar, migrar y actualizar sin perder identidad ni datos; y endurecer y diagnosticar, que es este capítulo.
6.2 Hacia dónde seguir
Los documentos de visión. El repositorio trae ocho: VISION.md como marco general, y después VISION_SOVEREIGN.md, VISION_AGENT.md, VISION_PROJECTS.md, VISION_MODERATION.md, VISION_REMOTE_AGENTS.md, VISION_ACTIVITY.md y VISION_MESH.md. El README los presenta como “la versión larga de lo que creemos que esto llega a ser”, con el pie de página de siempre.
Los NIPs propios de Buzz. En docs/nips/ viven las extensiones de protocolo que el proyecto escribió porque el ecosistema Nostr no las tenía, todas en estado draft optional:
| Código | De qué trata |
|---|---|
| NIP-OA | Owner Attestation: cómo una clave de dueño autoriza a una clave de agente a publicar bajo su propia autoría. La pieza que hace posible “identidad en vez de flags” |
| NIP-AA | Agent Authentication: cómo un relay con membresía NIP-43 trata a los agentes que portan credenciales NIP-OA |
| NIP-IA | Identity Archival: archivar y desarchivar identidades sin borrar historial y sin implicar reputación global |
| NIP-WP | Workspace Profile: el icono de workspace, escrito con un comando firmado kind:9033 y leído desde NIP-11 puro |
| NIP-CW | Channel Window: paginación por cursor calculada por el relay, servida como eventos firmados |
| NIP-RS | Sincronización de estado de lectura entre dispositivos. Explícitamente no es un protocolo de acuse de lectura |
Hay más — NIP-AE, NIP-AM, NIP-AO, NIP-AP, NIP-DV, NIP-ER, NIP-GS, NIP-MP, NIP-PL, NIP-PMA — y algunos traen verificación formal en docs/formal/.
Contribuir. CONTRIBUTING.md marca el camino:
git clone https://github.com/block/buzz.git
cd buzz
. ./bin/activate-hermit # toolchain pinneado con Hermit
just setup # herramientas, infraestructura y migraciones
just hooks # hooks de git, incluido el sign-off DCO automático
just ci # la misma compuerta que corre en CI, antes de abrir el PR
Reglas que conviene saber de antemano: cada commit necesita sign-off DCO (git commit -s) o el check bloquea el PR; el proyecto hace squash-merge, así que el título del PR es el subject del commit en main y debe seguir Conventional Commits con prefijo de tipo obligatorio (feat, fix, docs, refactor, test, chore); los PR asistidos por IA son bienvenidos y no hay que declarar las herramientas, pero el código final es tuyo y debes haberlo revisado — “los envíos que claramente no fueron revisados pueden cerrarse”; para cualquier cosa que no sea un arreglo pequeño, abrir un issue primero es muy recomendable; rara vez se mergean refactors grandes o cambios de dependencias sin issue previo, renombres cosméticos, features enteras sin discusión y cambios de paso mezclados en un arreglo no relacionado; si tocas la UI, el PR incluye capturas antes/después; y si just ci falla por formato, just fix-all lo arregla de una. Licencia Apache 2.0.
Si lo que quieres es extender el protocolo, CONTRIBUTING.md trae la receta de nueve pasos para añadir un kind nuevo: definir la constante en buzz-core/src/kind.rs, registrar el scope requerido en required_scope_for_kind(), manejar los efectos posteriores en handle_side_effects(), persistir si hace falta, indexar, testear y documentar. Con un aviso que resume la filosofía de la casa: para endpoints HTTP nuevos, prefiere un evento Nostr firmado y el camino de ingest existente antes que añadir APIs JSON específicas de endpoint. La superficie HTTP del relay es deliberadamente estrecha.
Resumen
- El modelo de seguridad se apoya en cinco capas: TLS fuera del relay, admisión con semáforo y límites de frame, identidad por NIP-42/NIP-98, autorización por membresía de canal, e integridad por firma Schnorr y audit log encadenado.
- La membresía de canal es el único mecanismo de control de acceso: no hay ACLs ni taxonomías de capacidades. El principio es identidad en vez de flags de permisos — los agentes tienen su propia clave, su propia membresía y su propio rastro de auditoría.
- El audit log es tamper-evident, no tamper-resistant: detecta ediciones sueltas, pero quien tenga escritura en Postgres puede recomputar la cadena entera.
- El relay no fuerza TLS a propósito; en Compose lo resuelve el override de Caddy, que además deja de publicar el puerto 3000 con
ports: !reset []. - El checklist de endurecimiento cubre identidad (
BUZZ_REQUIRE_AUTH_TOKEN,BUZZ_REQUIRE_RELAY_MEMBERSHIP,RELAY_OWNER_PUBKEY,BUZZ_RELAY_PRIVATE_KEY), superficie de red, límites de tamaño y de tasa, datos, imagen pinneada y multi-tenant. - Los defaults del binario son de desarrollo y los del
deploy/compose/.env.exampleson de producción: copiar el.envde desarrollo al VPS arranca un relay abierto. - El runbook cubre siete familias de fallo: relay que no arranca, Postgres saturado, Redis caído, medios que no suben, clientes que no autentican, eventos que no llegan y fallos silenciosos de welcome/kickoff. Los endpoints de diagnóstico son
/_liveness,/_readiness(con los booleanospostgresyredis) y/_status. - El rate limiting falla cerrado: Redis caído significa rechazo, no barra libre.
docs/welcome-kickoff-silent-failures.mddeja un patrón general valioso: “los hechos decoran; los temporizadores deciden”. La corrección es invertirlo — que decidan los hechos y que el cronómetro sea solo un último recurso.- Las vulnerabilidades se reportan a [email protected], nunca por issue público: acuse en 48 horas, respuesta completa en 7 días, divulgación coordinada y crédito en las notas de versión.
- Para seguir: los ocho documentos
VISION*.md, los NIPs propios endocs/nips/(empieza por NIP-OA y NIP-AA) yCONTRIBUTING.mdconjust setup,just hooks,just ciy el sign-off DCO obligatorio.
Con esto cierras el curso. Tienes un relay que puedes explicar, desplegar, gobernar, observar, respaldar, endurecer y reparar. 🐝