ExyonQ Servidor HTTP modular y reverse proxy en Rust

Conceptos

Cinco piezas explican casi todo el comportamiento de ExyonQ: listener, server, route, upstream y módulos. A ellas se suman dos reglas que conviene tener presentes: cómo se elige una ruta cuando varias encajan, y qué parte de la configuración cambia en caliente.

El modelo

Listener

Es la dirección que el proceso abre: [[server]].listen. Cada dirección declarada se abre y se sirve (multilistener). Sobre ese socket se cuelgan las opciones que no pueden ser por host:

Los bloques [[server]] que comparten un listen se colapsan en un solo socket. Por eso existen restricciones: todos deben habilitar TLS o ninguno, deben declarar el mismo proxy_protocol, y solo el primero puede omitir server_name, llevar acme, strict_sni, protocols, ciphers o curves.

Server

[[server]] agrupa tres cosas: server_name (nombres que identifican el bloque, usados para elegir certificado por SNI), la lista routes de rutas declaradas y el material TLS. No es un «virtual host» cerrado: la coincidencia de una petición se decide con el match de cada ruta, y esa identidad es global.

Route

[[route]] es la unidad de decisión. Tiene un name único, un match y exactamente una acción:

AcciónCampo
Servir ficherosroot (+ index)
Proxy HTTPupstream
FastCGIfastcgi (nombre de pool)
Redirecciónredirect
Reescritura internarewrite

Declarar dos acciones es un error de configuración (route ... defines conflicting actions), igual que no declarar ninguna (route ... must define upstream, root, redirect, rewrite, or fastcgi).

Upstream

[[upstream]] es un backend HTTP con nombre. O bien un target único (http://127.0.0.1:9000), o bien un conjunto endpoints con pesos y prioridades. Añade timeouts, política de selección, failover y health checks activos. Las rutas lo referencian por nombre; un nombre inexistente es error de configuración.

Módulos

Todo lo que no es enrutado vive en tablas propias: [modules.compression], [modules.metrics], [modules.ratelimit], [modules.real_ip], [static.preload], [static.encoding_cache], [full_page_cache], [http3], [waf] y [logging]. Están apagados por defecto salvo donde se indique lo contrario, y cada uno se documenta en Configuración.

Match y prioridad de rutas

Un match combina un patrón de ruta (exactamente uno) con filtros opcionales (AND):

CampoFunción
pathPrefijo de directorio; / es el catch-all
suffixSufijo literal (path.ends_with)
regexExpresión regular de Rust, sensible a mayúsculas, máximo 256 bytes
hostHost exacto o *.sufijo
header + header_valueCabecera presente y no vacía, o con valor exacto

path, suffix y regex son mutuamente excluyentes: ninguno o varios a la vez produce route match must set exactly one of path, suffix, or regex.

Cuando varias rutas encajan, se elige la mayor según esta tupla, en orden:

  1. Rango de host: host exacto (2) > *.sufijo (1) > sin host (0).
  2. Rango de tipo: regex y suffix (3) > prefijo normal (1) > catch-all / (0).
  3. Longitud del patrón: dentro del mismo tipo, gana el patrón más largo (/assets/img antes que /assets).
  4. Índice de declaración: a igualdad de todo lo anterior, gana la ruta declarada antes.

Es el criterio de «prefijo más largo» con dos correcciones: una regex o un sufijo siempre baten a un prefijo, y un host concreto siempre bate a un comodín.

Identidad de ruta

La identidad de una ruta es la tupla (host, tipo, patrón), con el host normalizado y el patrón sin barra final en el caso de prefijo. Es global para toda la configuración: dos rutas con la misma identidad hacen fallar la compilación del plan con duplicate route match identity host=… pattern=…. Esto es deliberado: evita que el orden de declaración decida en silencio qué ruta gana, y es la razón por la que los puertos 80 y 443 se reparten en dos procesos (ver Instalación).

Consecuencia práctica: para publicar el mismo patrón en cinco dominios hacen falta cinco rutas, una por host. Por eso las configuraciones reales son repetitivas: exyonq, exyonq_www, eqproxy, eqproxy_www… son la misma idea con distinto host.

Recarga frente a reinicio

Cada configuración válida compilada se publica como una generación numerada, con una huella (fingerprint) que la identifica. El proceso vivo conserva la generación actual y las peticiones en vuelo terminan con la que empezaron.

Recargar no es releer un fichero: es compilar un plan candidato, validarlo y publicarlo por el socket de control.

exyonqctl config reload /etc/exyonq/https.toml --diff     # clasifica sin publicar
exyonqctl config reload /etc/exyonq/https.toml --check    # preflight offline
exyonqctl reload -s /run/exyonq/https.sock                 # publica

--diff imprime current_fingerprint, candidate_fingerprint, las rutas añadidas y quitadas, y clasifica cada cambio. Un candidato idéntico se marca IDENTICAL_RELOAD = NO_OP. Si la compilación del candidato falla, la generación anterior sigue viva: la recarga no deja el servicio a medias.

Qué cambia en caliente

ElementoRecargaNotas
Rutas, hosts, rewrites, redireccionesSíGeneración nueva; las peticiones en vuelo siguen en la anterior
Upstreams, pesos, timeouts, health checksSí
Material TLS (cert, key)SíEl aceptador se reconstruye con las rutas válidas
Fichero OCSP (ocsp_stapling)SíSe relee al construir el aceptador; un reload basta aunque el TOML no cambie
WAFSíEl snapshot se compila en la fase de preparación y se intercambia al publicar
[static.preload]SíLa precarga se reconstruye con la generación; si falla, se conserva el snapshot anterior
Caché de página completaParcialLas entradas van ligadas a la generación; se pueden purgar por generación
[logging]Sí, con maticesCambiar sinks es configuración nueva
Dirección de escucha, http3_listen, habilitar un protocoloNoRequiere reinicio (EXY-RELOAD-0005)

La regla corta: si el cambio toca qué socket se abre, reinicia. Si toca cómo se atiende una petición en un socket ya abierto, recarga.

exyonqctl drain deja de aceptar trabajo nuevo y espera a que termine lo que está en vuelo. Los túneles WebSocket vivos sobreviven a una recarga y al drenaje; las peticiones nuevas durante el drenaje se rechazan con 503.