dec-0134

Vista generada de dec-0134: Origen TCP del daemon (`delivery-edge`): HTTPS HTTP/1.1 en Zig con Alt-Svc hacia HTTP/3

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0134-origen-tcp-del-daemon.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0134-origen-tcp-del-daemon.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoPROPOSED
Fecha2026-10-03
Ficherodocs/decisions/dec-0134-origen-tcp-del-daemon.md

Por qué importa (del frontmatter del ADR):

Fija cómo llega un navegador normal al daemon: un listener TCP propio (HTTPS HTTP/1.1 sobre TLS 1.3, ALPN http/1.1) en el mismo bucle que el listener HTTP/3, con Alt-Svc hacia h3, que despacha por el mismo código de política que el camino H3 (SCT deny-by-default, presupuesto de envío, raíces fd-relativas, CORS) y no por una copia. Sin este ADR, el origen TCP nacería como un servidor HTTP aparte con su propia comprobación de capacidades, o como un proxy delante del daemon que parte el «un solo salto» de P3.

Nodos del roadmap que lo citan en refs: ninguno.

Páginas de la documentación que lo citan: Desplegar styx en Coolify (especificado)

Texto del ADR

Leído de docs/decisions/dec-0134-origen-tcp-del-daemon.md, el fichero canónico.

dec-0134 — Origen TCP del daemon (delivery-edge): HTTPS HTTP/1.1 en Zig con Alt-Svc hacia HTTP/3

  • Fecha: 2026-10-03
  • Estado: PROPOSED. Concreta la decisión P3 de waxin (2026-10-01): «el delivery-edge es un listener TCP en el daemon: HTTPS h1/h2 propio en Zig, con Alt-Svc hacia HTTP/3. Un solo salto, owned e2e y el mismo en todos los runtimes» (docs/track/byte-runtime/plans/vertical-vod-web.md). Lo que falta ratificar es D2 (h2 queda fuera de esta entrega) y D5 (la recarga de certificado en el listener TCP y no en el QUIC).
  • Nodo: track/byte-runtime/tcp-edge (ticket #01). Da hogar a delivery-edge (r17, «aún sin home»): no es un servicio ni un contenedor nuevo, es una función del daemon.
  • Relacionados: dec-0117 (seguridad del data plane: SCT deny-by-default, presupuestos, raíces fd-relativas), dec-0110 §3 (entrega empaquetada /styx/pkg), dec-0123 (despliegue: Coolify, TLS), dec-0120 (spire: D7 sube MediaOpenPackaging a v3 por el canal de control), r01 (los bytes de vídeo no atraviesan JS), r03 (sin FUSE), dec-0103 (zkit).

Contexto

Hoy el daemon sólo habla HTTP/3 (QUIC, UDP) con un certificado que el navegador no conoce. El e2e web-vod llega a él porque Chromium arranca con --origin-to-force-quic-on y --ignore-certificate-errors-spki-list; un Chrome o un Safari normales no lo alcanzan: un navegador sólo prueba HTTP/3 con un origen del que ya recibió un Alt-Svc por TCP, y no hay ningún origen TCP. La ola A de la vertical («una película entera, desde el SSD, en un Chrome y un Safari sin flags») no cierra sin un origen alcanzable con un certificado en el que el navegador confíe.

Lo que ya existe y no se duplica:

  • Toda la política de la entrega vive en H3RangeHandler (transport/h3z_transport.zig): admisión de la SCT por petición, authorize por rango, lease de conexión por actor, strikes, tope de ventana, registro de peticiones cancelables, CORS de /styx/pkg.
  • El presupuesto de envío es el send_budget.Ledger y la producción de segmentos es el h3_packaging.Pump (hilos productores, topes de bytes retenidos, plazos, cancelación).
  • El fork quic-zig ya trae un servidor TLS 1.3 sin E/S propia (tls/server.zig) y un parser HTTP/1.1 (http1/parser.zig); su listener HTTP/1.1 sólo sirve ficheros estáticos y WebSocket, sin gancho de petición. Mover el pin del submódulo para añadírselo necesita el OK de waxin (adopción), así que el daemon pone su propio listener sobre esas dos piezas.

Decisiones

D1 — Un listener TCP en el daemon, en el bucle del listener H3

transport/tcp_edge.zig registra su socket de escucha y sus conexiones en el bucle de eventos (event_loop.Server.eventLoop()) del H3ZTransport, el mismo hilo. Consecuencias buscadas:

  • Un solo salto: el navegador habla con el daemon, sin proxy propio ni JS en medio (r01).
  • Sin cerrojos nuevos: el ledger de envío, el Deadline de admisión y el Pump son «sólo hilo del bucle», y el listener TCP los comparte con el H3 (los presupuestos son del transporte, no del protocolo: un actor no duplica su cuota abriendo una conexión TCP además de una QUIC).
  • Si el listener H3 no arranca no hay bucle, y por tanto tampoco origen TCP. Es el mismo contrato de «opcional» que ya tiene H3.

D2 — HTTP/1.1 sobre TLS 1.3 ahora; HTTP/2 queda fuera de esta entrega

El listener negocia ALPN http/1.1. HTTP/2 (HPACK, control de flujo por stream y por conexión, multiplexado) es una superficie de ataque grande (rapid reset, CONTINUATION flood, HPACK bomb) que entraría en el hot path del daemon, y no aporta lo que esta vertical necesita: Chromium abre hasta 6 conexiones por origen, cada petición Range o de segmento ocupa una conexión sólo mientras dura y el Alt-Svc mueve al navegador a HTTP/3 (que sí multiplexa) en cuanto lo conoce. El h2 se mide primero: el ticket de seguimiento exige TTFF y seek h1 frente a H3 con datos antes de escribirlo, y un h2 propio sólo se justifica si h1 pierde donde importa. Esto se aparta de la letra de P3 («h1/h2») y por eso D2 está PROPOSED.

Medida (ticket #05, loopback, N=10 corridas alternadas, 20 seeks por corrida, mismo daemon, fixture y Chromium; evidence/browser/edge-compare.json): TTFF mediana 167 ms por h1 frente a 149 ms por H3 forzado; seek mediana 88 ms frente a 87 ms; seek p95 119 ms frente a 115 ms. Por TCP no hay diferencia apreciable en el seek y el TTFF cuesta unos 18 ms más (el handshake TCP+TLS antes del primer byte). No justifica escribir un h2: el único camino donde h2 ganaría a h1 (varias peticiones concurrentes sobre una conexión con RTT) no se ve en loopback, y ahí el Alt-Svc lleva al navegador a H3. Límite de la medida: sin RTT ni pérdida; la comparación en red real queda para la verificación en el Mac de waxin (A5).

D3 — Una sola política: el despachador TCP llama al mismo código que H3

El listener sólo hace lo que es del protocolo: TLS, enmarcado HTTP/1.1, límites de cabeceras y tiempos. Construye la lista de cabeceras que el despachador H3 ya consume (:method, :path, :authority y el resto en minúsculas) y llama a handleRequest. Para eso el despachador, el Pump, el Ledger y el Deadline trabajan contra un Peer (transport/peer.zig): una unión de la sesión QUIC y de la conexión TCP con las operaciones que ya usaban (sendResponse, sendResponseHeaders/Data/finish, streamBufferedBytes, notifyWritable, reset, closeConnection, id). No hay una segunda comprobación de capacidades: cualquier cambio en la política de H3 lo es en TCP, y los tests de política corren sobre los dos.

Una conexión HTTP/1.1 atiende una petición a la vez (sin multiplexado): la siguiente se lee cuando la respuesta anterior se escribió entera. El «stream» de la petición es su número de orden en la conexión.

D4 — Alt-Svc

Toda respuesta del listener TCP lleva Alt-Svc: h3=":<puerto UDP>"; ma=86400, salvo STYX_MEDIA_TCP_ALT_SVC=off. El puerto TCP por defecto es el del UDP del listener H3 (STYX_MEDIA_H3_PORT, 4435): el mismo host:puerto sirve las dos y el endpoint que anuncia playback-svc no cambia de autoridad.

D5 — Certificado desde configuración y recarga

  • STYX_MEDIA_TCP_CERT / STYX_MEDIA_TCP_KEY (por defecto STYX_MEDIA_H3_CERT / _KEY, que a su vez caen en STYX_MEDIA_CERT / _KEY): PEM con cadena completa (el fullchain.pem de Let's Encrypt o el que monte Coolify) y clave ECDSA P-256, Ed25519 o RSA.
  • Recarga: cada STYX_MEDIA_TCP_CERT_RELOAD_S (por defecto 30; 0 = desactivada) el listener lee los dos ficheros enteros y compara su huella SHA-256 (no la fecha ni el tamaño), de forma síncrona en el hilo del loop de medios; son dos PEM pequeños. Si la huella cambia, los valida (cadena y clave parseables, la clave corresponde al certificado hoja) y los instala para los handshakes siguientes; las conexiones vivas siguen con el material con el que empezaron, que se libera cuando se cierra la última. Si el material nuevo no valida, se queda el anterior y se registra un aviso: una renovación a medio escribir no tumba el listener.
  • Límite conocido: el listener QUIC no se puede recargar sin reiniciar (el fork carga el material una vez). Tras una renovación el TCP sirve el certificado nuevo y el H3 sigue con el viejo hasta el siguiente reinicio, que es válido hasta su caducidad. Queda como seguimiento.
  • TLS terminado por un proxy delante: STYX_MEDIA_TCP_TLS=off sirve HTTP/1.1 en claro, pensado sólo para detrás de Traefik/Coolify en una red privada; el daemon lo avisa en el arranque y la SCT sigue siendo obligatoria. Con el paso de TLS por Traefik (passthrough por SNI) no hace falta: el daemon termina su propio TLS.

D6 — Límites del listener

Mismos valores que el H3 (conn_admission.listener: 256 conexiones, 16 por dirección, 8 por actor), pero la tabla del TCP es propia: los totales se suman a los del QUIC, no se comparten. El Deadline de capacidad y el presupuesto sí son los mismos. Cada handshake TLS (~1 ms) corre en el hilo del loop que comparte el H3, y el tope de conexiones abiertas no limita la velocidad de apertura: el listener limita además las conexiones nuevas por segundo, 32 por dirección (STYX_MEDIA_TCP_ACCEPT_RATE_PER_ADDRESS) y 256 en total, y corta al aceptar las que sobran (stats.rate_limited). La clave de dirección IPv6 es el prefijo /64, como en QUIC. Cabecera, cabecera de petición de a lo sumo 16 KiB y 100 cabeceras, 10 s para completar el handshake y la primera cabecera, 15 s de inactividad en keep-alive, el Deadline de capacidad (10 s sin una SCT admitida, 4 rechazos) y el presupuesto de envío compartido. Sin cuerpo de petición (GET, HEAD y OPTIONS), sin Upgrade, sin Transfer-Encoding.

D7 — Descriptor de entrega

playback-svc anuncia en el descriptor el origen TCP junto al H3 (tcpEndpoint/protocols), y el reproductor prefiere el que el navegador alcanza (HTTPS por TCP; el Alt-Svc hace el resto). Contrato en docs/protocols/. El web (apps/web/src/server/playback-origin.ts) toma tcpEndpoint si viene y endpoint si no; con STYX_MEDIA_TCP_PUBLIC_PORT (proxy) sólo el primero lleva el puerto publicado.

Consecuencias

  • El e2e web-vod corre sin banderas de QUIC ni de certificado: usa una CA local de prueba instalada en el perfil NSS del navegador del harness.
  • El h2 y la recarga del certificado QUIC quedan como seguimientos explícitos del nodo.
  • Un TCP + TLS hecho en casa es superficie nueva: lo cubren tests de política sobre los dos transportes, un fuzz del parser de petición, TSAN y el audit de seguridad del daemon.

Preguntas abiertas

  1. ¿PROXY protocol? Detrás de un proxy sin él (passthrough por SNI o TCP_TLS=off tras Traefik) todos los espectadores llegan desde la dirección del proxy y comparten las 16 conexiones y las 32 aperturas por segundo. Paliativo hoy: STYX_MEDIA_TCP_MAX_PER_ADDRESS y STYX_MEDIA_TCP_ACCEPT_RATE_PER_ADDRESS (0 = sin tope; el tope total de 256 y el Deadline por actor siguen). Lo correcto es PROXY protocol v2 desde un proxy de confianza (hay que decidir la lista de pares de confianza para que no se falsifique); sin implementar.

  2. ¿HTTP/2 propio? Sólo si la medida h1-frente-a-H3 (ticket #05) lo pide. Hasta entonces no.

  3. ¿Recarga del listener QUIC? Requiere un cambio en el fork (pin) o reiniciar el daemon tras una renovación; waxin decide si se pide el cambio al fork.

  4. ¿Safari? Sin Mac en este entorno: la ola A exige comprobarlo en el Mac de waxin (A5).

  5. ¿Despliegue? Propuesta (ticket #06, pendiente de OK de waxin): 4435/tcp publicado directo en el host, como la QUIC (0.0.0.0:4435:4435/tcp en styx-quic, listen del modelo con exposure: public, igual en compose, Coolify, Quadlet y nativo), y no un router TCP de Traefik con HostSNI y tls.passthrough=true. Motivos: el daemon termina su TLS con el mismo certificado que la QUIC (los secretos media_tls_cert/_key, sin variables nuevas), ve la dirección real del cliente (sus topes por dirección valen sin PROXY protocol), Alt-Svc anuncia el puerto UDP publicado sin STYX_MEDIA_TCP_PUBLIC_PORT, sigue siendo un salto y check:deploy ya prohibía los routers traefik.tcp. Coste: un puerto TCP más abierto en el host (filtrado por host-firewall.sh con STYX_QUIC_ALLOW_FROM, que ahora cubre UDP y TCP), y el certificado de la QUIC tiene que ser de una CA pública para STYX_MEDIA_WT_HOST (el ACME de Traefik no le sirve: no es un fichero del daemon). Si waxin prefiere el 443 por SNI, hace falta antes la pregunta 0 (PROXY protocol) o subir los topes por dirección, y relajar check:deploy. Pendientes de waxin:

    • Renovación: los secretos de fichero se montan al crear el contenedor (compose), se copian (Podman) o se cargan al arrancar (LoadCredential), así que la recarga en caliente del TCP sólo ve una renovación escrita sobre el mismo fichero (mismo inodo, compose). Como la QUIC no recarga (pregunta 2), la receta documentada es reiniciar el daemon tras renovar. ¿Montar el directorio del certificado (no como secreto) para que la recarga del TCP valga sola?
    • Nombre: STYX_QUIC_ALLOW_FROM filtra ya la QUIC y el origen TCP; ¿renombrarlo (con alias) a algo como STYX_DAEMON_ALLOW_FROM?
  6. ¿Alt-Svc con una CA privada? Con la CA local del harness, Chromium recibe y parsea el Alt-Svc (el net-log lo registra) e intenta QUIC, pero el handshake QUIC no-forzado termina con certificate unknown, mientras que el forzado (--origin-to-force-quic-on) con la misma CA y el mismo certificado funciona; por TCP el certificado se acepta. La causa no está establecida (hipótesis: Chromium no usa QUIC alternativo con raíces locales no públicas). Con un certificado público (Let's Encrypt en Coolify) debería subir a h3; está sin verificar.