docs(issues): encolar 0002 (media v2), 0003 (descentralización HA), 0004 (hardening seguridad)
Specs de los tres issues siguientes del bus, derivados de esta sesión: - 0002 media v2: chunking, mimetype, GC del object store, exponer en clientes. - 0003 descentralización/HA: cluster NATS magnus+homer (R1→R3), control plane SQLite→JetStream KV, quorum, failover. Tercer nodo = homer (141.94.69.66). - 0004 hardening: cierra los hallazgos de la auditoría red-team (report 0004): DoS pre-auth, fail-open, autorización por pertenencia, ACL NATS, TLS control plane.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
---
|
||||
issue: 0002
|
||||
title: Media v2 — archivos grandes (chunking), metadata, GC del object store, exponer en clientes
|
||||
status: spec
|
||||
created: 2026-06-07
|
||||
domain: media
|
||||
scope: unibus (pkg/blobstore, pkg/frame, pkg/client, pkg/membership) + clientes (mobile binding, gateway web, unibots)
|
||||
depends_on: 0001 (la auth firmada del control plane debe cubrir /blobs antes de exponer media)
|
||||
---
|
||||
|
||||
# Objetivo
|
||||
|
||||
El envío de archivos (imágenes, audio, vídeo) ya funciona en v1, pero con límites
|
||||
que lo hacen inviable para vídeo grande y poco usable para los clientes. Este issue
|
||||
lleva la media a un estado de producción: archivos grandes por chunks, metadata de
|
||||
tipo/nombre, recolección de basura del object store, y exposición en los frontends.
|
||||
|
||||
# Contexto — cómo funciona media v1 (hoy)
|
||||
|
||||
`PublishMedia(roomID, data []byte)` cifra el archivo **entero** con la clave de la
|
||||
room (`SealAEAD`), lo sube **entero** al object store (`pkg/blobstore`,
|
||||
content-addressed por hash) vía el control plane (`POST /blobs`), y publica por el
|
||||
bus solo una referencia `frame.BlobRef{Hash, Nonce, Size}`. `FetchMedia` baja el
|
||||
ciphertext por hash (`GET /blobs/{hash}`) y lo descifra. El binario nunca viaja por
|
||||
NATS; el bus solo lleva la referencia. El object store guarda solo ciphertext (E2E
|
||||
real). Es correcto y simple, pero:
|
||||
|
||||
| Limitación v1 | Consecuencia |
|
||||
|---|---|
|
||||
| Todo el archivo en RAM (cifra y sube de una vez) | imágenes/audio OK; vídeo grande (cientos MB/GB) revienta memoria |
|
||||
| `BlobRef` solo lleva hash+nonce+size | el receptor no sabe mimetype/filename; no puede renderizar bien |
|
||||
| Sin resumable | si falla la subida de un archivo grande, reempezar de cero |
|
||||
| Object store sin GC | blobs content-addressed crecen indefinidamente, sin refcount ni TTL |
|
||||
| `mobile/` solo expone `Publish` (texto) | no se puede enviar una foto desde el móvil |
|
||||
| Gateway web sin endpoints de media | la SPA no sube/baja archivos |
|
||||
|
||||
Fuera de alcance de este issue (sería otro): **streaming en vivo** (videollamada,
|
||||
audio en tiempo real) — eso no es modelo blob, requiere WebRTC señalizado por el bus.
|
||||
|
||||
# Diseño
|
||||
|
||||
## Pieza 1 — Chunking de archivos grandes
|
||||
|
||||
Partir el archivo en chunks de tamaño fijo (propuesta: 4 MB), cifrar **cada chunk**
|
||||
de forma independiente con la clave de la room (nonce por chunk), y subir cada chunk
|
||||
como un blob propio (content-addressed). La referencia pasa de un solo blob a un
|
||||
manifiesto de chunks.
|
||||
|
||||
- `frame.BlobRef` evoluciona (de forma compatible) a soportar lista de chunks:
|
||||
```
|
||||
BlobRef{
|
||||
Hash string // hash del manifiesto (o del blob único si no hay chunks)
|
||||
Nonce []byte // nonce del manifiesto / del blob único
|
||||
Size int64 // tamaño total en claro
|
||||
Chunks []ChunkRef // vacío en archivos pequeños (camino v1 intacto)
|
||||
}
|
||||
ChunkRef{ Hash string; Nonce []byte; Size int64 } // por chunk cifrado
|
||||
```
|
||||
- `PublishMediaStream(roomID string, r io.Reader, meta MediaMeta) (BlobRef, error)`:
|
||||
lee del `io.Reader` en chunks (no carga el archivo entero en RAM), cifra y sube
|
||||
cada chunk, y construye el manifiesto. El `PublishMedia([]byte)` v1 se mantiene
|
||||
como atajo para archivos pequeños (sin chunks).
|
||||
- `FetchMediaStream(roomID, BlobRef) (io.ReadCloser, error)`: baja y descifra chunks
|
||||
bajo demanda, exponiendo un `io.Reader` (descarga progresiva, no todo en RAM).
|
||||
- Subida/descarga de chunks en paralelo acotado (p. ej. 4 a la vez) para throughput.
|
||||
|
||||
## Pieza 2 — Metadata (mimetype + filename)
|
||||
|
||||
Añadir a `BlobRef` (o a un sidecar cifrado) los campos `Mime string` y `Name
|
||||
string`, de modo que el receptor sepa renderizar (imagen inline, reproductor de
|
||||
audio/vídeo, icono de descarga). Como `Name`/`Mime` pueden ser sensibles, viajan
|
||||
**dentro del campo cifrado** del frame, no en claro. Detección de mimetype por
|
||||
sniffing del primer chunk + extensión.
|
||||
|
||||
## Pieza 3 — Garbage collection del object store
|
||||
|
||||
Hoy los blobs no se borran nunca. Introducir refcount o barrido:
|
||||
|
||||
- **Refcount por referencia**: una tabla `blob_refs(hash, room_id, msg_id)` en el
|
||||
control plane; al expirar un mensaje de una room efímera o al purgar historial de
|
||||
una room persistente, decrementar y borrar el blob cuando llega a cero.
|
||||
- **Alternativa TTL**: blobs de rooms efímeras con TTL; blobs de rooms persistentes
|
||||
viven mientras viva el mensaje en JetStream.
|
||||
- Comando `membershipd blobs gc [--dry-run]` para barrido manual + métrica de
|
||||
espacio. Debe ser idempotente y seguro (nunca borrar un blob aún referenciado).
|
||||
|
||||
## Pieza 4 — Exponer media en los clientes
|
||||
|
||||
- **Binding móvil** (`mobile/unibus.go`): `SendFile(roomID, path, mime)` y
|
||||
`FetchFile(roomID, frameJSON) -> path` (escribe a un archivo local del sandbox de
|
||||
la app y devuelve la ruta; no pasa []byte grandes por el puente gomobile).
|
||||
- **Gateway web** (`playground/server.go`): `POST /api/media` (multipart, streaming
|
||||
al store) y `GET /api/media/{room}/{hash}` (descarga descifrada con los headers
|
||||
`Content-Type`/`Content-Disposition` derivados de la metadata).
|
||||
- **unibots**: una tool `send_file` para que un bot pueda adjuntar archivos.
|
||||
|
||||
# Decisiones técnicas
|
||||
|
||||
| Decisión | Elegido | Alternativa | Razón |
|
||||
|---|---|---|---|
|
||||
| Tamaño de chunk | 4 MB | 1 MB / 16 MB | equilibrio RAM vs overhead de manifiesto |
|
||||
| Cifrado por chunk | nonce independiente por chunk, misma clave de room | re-cifrar todo | permite descarga/borrado parcial y paralelismo |
|
||||
| Metadata sensible | dentro del frame cifrado | en claro en BlobRef | filename/mime pueden filtrar info |
|
||||
| GC | refcount en control plane | solo TTL | preciso, no borra lo aún referenciado |
|
||||
| Compatibilidad v1 | `Chunks` vacío = camino v1 | romper formato | no romper media ya enviada |
|
||||
|
||||
# Fases (TBD, ramas `issue/0002x-*`)
|
||||
|
||||
1. **0002a — BlobRef con chunks (compatible)** — extender el tipo + tests de
|
||||
marshalling con `Chunks` vacío (v1) y con chunks (v2). Sin cambiar clientes aún.
|
||||
2. **0002b — PublishMediaStream / FetchMediaStream** — API de streaming en
|
||||
`pkg/client` sobre `io.Reader`/`io.ReadCloser`, cifrado por chunk, subida/descarga
|
||||
paralela acotada. Tests con un archivo > tamaño de chunk.
|
||||
3. **0002c — metadata mime+name** (en el campo cifrado) + sniffing.
|
||||
4. **0002d — GC del object store** — refcount + `membershipd blobs gc` + tests de
|
||||
"no borrar referenciado / borrar huérfano".
|
||||
5. **0002e — exponer en clientes** — binding móvil (`SendFile`/`FetchFile`), gateway
|
||||
web (`/api/media`), tool `send_file` en unibots.
|
||||
|
||||
# Definition of Done (evidencia ejecutable)
|
||||
|
||||
- **Golden:** enviar y recibir una imagen pequeña (camino v1, sin chunks) sigue
|
||||
funcionando; enviar y recibir un archivo de 50 MB por chunks sin cargar 50 MB en
|
||||
RAM (medir RSS durante la operación).
|
||||
- **Edge:** archivo cuyo tamaño es múltiplo exacto del chunk; archivo de 1 byte;
|
||||
archivo justo por debajo y por encima del umbral de chunking.
|
||||
- **Error path:** chunk corrupto/no descifrable → error claro, no panic; `blobs gc`
|
||||
con un blob aún referenciado → NO lo borra (assert).
|
||||
- `CGO_ENABLED=0 go test ./...` verde.
|
||||
|
||||
# Riesgos y mitigaciones
|
||||
|
||||
| Riesgo | Mitigación |
|
||||
|---|---|
|
||||
| Romper media v1 ya enviada | `Chunks` vacío preserva el camino v1; tests de compatibilidad |
|
||||
| GC borra un blob aún referenciado | refcount + barrido conservador + `--dry-run` por defecto en CI |
|
||||
| Puente gomobile con []byte grandes | el binding trabaja con rutas de archivo, no buffers en memoria |
|
||||
| Paralelismo de chunks satura el control plane | límite de concurrencia (4) + el endurecimiento de auth del issue 0001 |
|
||||
|
||||
# Relación con otros issues
|
||||
|
||||
- **0001 (seguridad)** — prerequisito: la auth firmada del control plane debe cubrir
|
||||
`POST/GET /blobs` antes de exponer media públicamente; si no, cualquiera llena el
|
||||
store o descarga ciphertext ajeno.
|
||||
- **Streaming en vivo** (futuro, no este issue) — videollamada/audio en tiempo real =
|
||||
WebRTC con el bus como canal de señalización; modelo distinto al blob.
|
||||
Reference in New Issue
Block a user