Vista generada de dec-0134: Origen TCP del daemon (`delivery-edge`): HTTPS HTTP/1.1 en Zig con Alt-Svc hacia HTTP/3
docs/decisions/dec-0134-origen-tcp-del-daemon.mdVista generada desde
docs/decisions/dec-0134-origen-tcp-del-daemon.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | PROPOSED |
| Fecha | 2026-10-03 |
| Fichero | docs/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)
Leído de docs/decisions/dec-0134-origen-tcp-del-daemon.md, el fichero canónico.
delivery-edge): HTTPS HTTP/1.1 en Zig con Alt-Svc hacia HTTP/3delivery-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).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.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).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:
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.send_budget.Ledger y la producción de segmentos es el
h3_packaging.Pump (hilos productores, topes de bytes retenidos, plazos, cancelación).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.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:
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).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).
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.
Alt-SvcToda 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.
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.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.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.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.
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.
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.¿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.
¿HTTP/2 propio? Sólo si la medida h1-frente-a-H3 (ticket #05) lo pide. Hasta entonces no.
¿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.
¿Safari? Sin Mac en este entorno: la ola A exige comprobarlo en el Mac de waxin (A5).
¿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:
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?STYX_QUIC_ALLOW_FROM filtra ya la QUIC y el origen TCP; ¿renombrarlo (con alias)
a algo como STYX_DAEMON_ALLOW_FROM?¿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.