EQProxy Load balancer y reverse proxy en Rust

Configuración

Toda la configuración de EQProxy vive en un único fichero TOML con schema_version = 1. Esta página recoge las claves que existen —tipo, defecto y nota— por sección, con un ejemplo mínimo por bloque.

El esquema real es eqproxy.schema.json, regenerable con eqproxy config schema; eqproxy.toml.example es el fichero comentado que repasa las mismas claves.

Estructura del fichero

El esquema es cerrado (additionalProperties: false en todas las tablas): una clave con errata se rechaza, no se ignora. schema_version es obligatoria y solo vale 1. Las duraciones son cadenas con sufijo ("500ms", "5s", "1h"). Las rutas relativas —certificados, mapas, páginas de error, caché en disco— se resuelven desde el directorio del TOML, no desde el directorio de trabajo.

Los nombres de listener y backend son claves de tabla ([listener.web], [backend.api]). Un listener exige listen y backend; un backend exige server o servers, nunca los dos.

Global y métricas

NombreTipoDefectoNotas
schema_versionentero— (obligatorio)Único valor aceptado: 1
shutdown_graceduración"10s"Drenaje de conexiones vivas al apagar con SIGINT
[metrics] detailedbooleanofalseContadores por servidor y por ruta; recargable
[metrics] max_seriesentero128Presupuesto de series, 1..=4096; fijo al arrancar
schema_version = 1
shutdown_grace = "10s"

[metrics]
detailed = false
max_series = 128

Listeners

NombreTipoDefectoNotas
listencadena— (obligatorio)IP:PORT; el puerto 0 pide uno efímero
backendcadena— (obligatorio)Backend de respaldo cuando ninguna regla coincide
modecadena"tcp""tcp" (túnel de bytes) o "http" (HTTP/1.1 estricto)
maxconnentero1024Conexiones concurrentes; al llegar al tope se rechaza al recién llegado
connect_timeoutduración"5s"Conexión al backend
idle_timeoutduración"60s"Inactividad por dirección
client_header_timeoutduración"5s"Solo http: recibir la cabecera de la petición
server_header_timeoutduración"30s"Solo http: recibir la cabecera de la respuesta
max_stream_durationduración"0s"Tope de duración total del intercambio; "0s" lo desactiva
forwarded_headerscadena"off""off", "append" o "replace"
proxy_receivebooleanofalseAceptar PROXY protocol v1; exige proxy_allow con IPs o CIDR
quicbooleanofalseHTTP/3 sobre QUIC en la misma IP:PORT por UDP; exige TLS y mode = "http"
ruleslista de tablas[]Reglas ordenadas; gana la primera que coincide
error_pageslista de tablas[]Páginas de error propias, por código
local_responsetablaausenteRespuesta propia cuando no hay servidor disponible
[listener.web]
listen = "0.0.0.0:8080"
mode = "http"
backend = "api"
maxconn = 2048
forwarded_headers = "append"

Timeouts

Cada fase tiene su reloj y su respuesta observable; config explain imprime las dos cosas antes de que nada ocurra.

TimeoutQué mideAl dispararse
connect_timeoutConectar con el backend502
client_header_timeoutRecibir la cabecera de la petición408 en el primer intercambio; cierre después
server_header_timeoutRecibir la cabecera de la respuesta504
idle_timeoutInactividad en una direcciónCierre, también en túneles WebSocket, CONNECT y tcp
max_stream_durationDuración total del intercambio504 antes de la cabecera; flujo truncado después
timeout_queueEspera en la cola del backend503 explícito
health_check.timeoutUn intento de comprobaciónCuenta como fallo

Inactividad y duración son cosas distintas: idle_timeout corta un intercambio que no se mueve; max_stream_duration, uno que se mueve pero dura demasiado.

Conexiones y límites

NombreTipoDefectoNotas
listener.maxconnentero1024Tope de conexiones concurrentes del listener
servers[].maxconnentero00..=1000000; 0 = solo lo limita el listener; al llegar al tope el balanceador deja de elegirlo
limits.max_head_bytesentero163841024..=1048576; aplica a la cabecera de petición y de respuesta
limits.max_headersentero1001..=1000 cabeceras por mensaje
limits.max_body_bytesentero00 = sin límite; se responde 413 antes de conectar si la longitud viene declarada
limits.stream_buffer_bytesenteroausenteTubería de cada flujo h2/h3/WebSocket: 1024..=1048576; ausente = 64 KiB (16 KiB en h3)
[listener.web.limits]
max_head_bytes = 16384
max_headers = 100
max_body_bytes = 1048576

Cuando todos los servidores de un backend están en su maxconn, la petición falla de forma explícita con no_available_server y, si hay cola, espera en ella.

TLS en el listener

NombreTipoDefectoNotas
certruta— (obligatorio)Cadena PEM, hoja primero
keyruta— (obligatorio)Clave PEM: PKCS#8, PKCS#1 o SEC1
alpnlista[]Por ejemplo ["h2", "http/1.1"]; vacío = sin ALPN; "h3" se rechaza aquí
min_versioncadena"1.2"TLS 1.0 y 1.1 nunca se aceptan
max_versioncadenaausente (= 1.3)"1.2" limita a TLS 1.2; no puede quedar por debajo de min_version
client_carutaausentePresente = mTLS: el certificado de cliente debe encadenar con esta CA
certslista de tablas[]Certificados adicionales por SNI: cert, key y names
[listener.web.tls]
cert = "certs/fullchain.pem"
key = "certs/privkey.pem"
alpn = ["h2", "http/1.1"]

[[listener.web.tls.certs]]
cert = "certs/api.pem"
key = "certs/api.key"
names = ["api.example.com", "*.api.example.com"]

El certificado por defecto responde a los clientes sin SNI y a los nombres que ninguna entrada reclama: un nombre desconocido no hace fallar el handshake. La selección es nombre exacto y después el comodín más largo.

Reglas de enrutamiento

Cada regla de [listener.*.rules] suma condiciones (AND) y lleva exactamente una acción; el orden del array es el orden de evaluación.

NombreTipoDefectoNotas
namecadena"rule-N"Nombre para explain y logs
host / host_prefix / host_regexcadena—Host sin puerto, sin distinguir mayúsculas
path / path_prefix / path_regexcadena—Sin normalizar: /v1 también casa con /v10
methods, headers, cookies, iplista[]Cabeceras y cookies con exact, regex o present = true
maptablaausente{ name, key o header, equals }: consulta un [maps.*]
use_backendcadena—Acción: enviar al backend
splitlista—Acción: reparto ponderado [{ backend, weight }]
redirecttabla—Acción: 3xx local con status y location
direct_responsetabla—Acción: cuerpo local con status, body y content_type
rewritetabla—Solo con use_backend: path_prefix_rewrite, host_rewrite
set_headers / add_headers / del_headerslista[]Solo con use_backend; valores con $remote_addr, $host, $scheme
response_headerslista[]Cabeceras que ve el cliente, con las mismas plantillas
rate_limittablaausente{ rate, per, burst }; cubo por IP de cliente y por regla
corstablaausenteallow_origin obligatorio; el preflight se contesta localmente
authtablaausenteurl (subpetición http://) y/o type = "basic" con users htpasswd
[[listener.web.rules]]
name = "api-v1"
host = "api.example.com"
path_prefix = "/api/v1/"
use_backend = "api"
rate_limit = { rate = 10, per = "1s", burst = 20 }

[listener.web.rules.rewrite]
path_prefix_rewrite = "/api/"

Un 429 por rate limit es siempre explícito y suma rate_limited. El cubo es local al proceso: con varias instancias, cada una lleva el suyo.

Upstreams y balanceo

NombreTipoDefectoNotas
servercadena—Un solo servidor (IP:PORT, HOSTNAME:PORT o unix:/ruta); excluyente con servers
serverslista de tablas—addr, weight, backup, priority, maxconn, source
balancecadena"roundrobin""roundrobin" (ponderado suave) o "leastconn"
slow_startduración"0s"Rampa del peso efectivo tras volver a operativo; máximo "1h"
all_backupsbooleanofalseReparte entre todos los respaldos en vez de un escalón cada vez
resolve_intervalduración"30s"Re-resolución de servidores con nombre; "0s" = solo al arrancar
proxy_protocolcadena"none""v1" o "v2" hacia el servidor
maxqueueentero0Cola acotada cuando no hay servidor disponible; 0 = rechazar ya
timeout_queueduración"5s"Espera máxima en esa cola
retriesentero00..=5 redes de conexión
retry_non_idempotentbooleanofalsePermite reintentar POST y compañía
affinitytablaausentemode = "cookie" (cookie eqproxy_affinity por defecto) o "source"
[backend.*.tls]tablaausenteverify (true), ca_file, server_name, alpn, min_version

En servers, los pesos van de 0 a 256 y weight = 0 deja el servidor drenado sin tocar el resto. priority (0..=255) solo vale con backup = true y ordena los escalones de failover; all_backups y priority juntos son un error de configuración. Un source fija la IP local de salida y solo vale para destinos TCP.

[backend.api]
balance = "leastconn"
servers = [
  { addr = "10.0.0.11:9000", weight = 3 },
  { addr = "10.0.0.12:9000", weight = 1 },
  { addr = "10.0.0.13:9000", weight = 1, backup = true },
]
maxqueue = 256
timeout_queue = "500ms"

Health checks

NombreTipoDefectoNotas
typecadena"tcp""tcp" (solo conectar) o "http" (un intercambio real)
intervalduración"2s"Entre ciclos
timeoutduración"1s"Por intento: conexión, petición y respuesta
fall / riseentero3 / 2Fallos consecutivos para caer; éxitos para volver
passivebooleanofalseEl tráfico vivo (5xx, fallos de conexión) alimenta la misma máquina
http_path / http_methodcadena"/" / "GET"Solo type = "http"; el método, GET o HEAD
http_hostcadenala dirección del servidorCabecera Host de la sonda
expect_statuscadena"200-299"Códigos o rangos aceptados
expect_rstringcadenaausenteRegex que debe aparecer en el cuerpo (se lee hasta 64 KiB)
[backend.api.health_check]
type = "http"
interval = "2s"
fall = 3
rise = 2
http_path = "/healthz"
expect_status = "200-299"

Sin sección health_check, todos los servidores se consideran operativos. Una comprobación correcta no demuestra que la aplicación funcione: prueba lo que le pidas y nada más.

Caché

Solo en mode = "http" y solo si la sección existe.

NombreTipoDefectoNotas
default_ttlduración"60s"1s..=24h; s-maxage/max-age solo acortan
max_memory_bytesentero671088641 MiB..=1 GiB; desalojo LRU al superarlo
max_entry_bytesentero104857664 KiB..=64 MiB y nunca mayor que la memoria
disk_pathrutaausentePersistencia en disco; sin ella, solo memoria
[listener.web.cache]
default_ttl = "60s"
max_memory_bytes = 67108864
disk_path = "cache/web"

Solo se almacenan GET con estados 200, 301, 308, 404 y 410 y sin Set-Cookie. Las respuestas servidas llevan X-Cache: HIT, MISS o REVALIDATED.

Para comprimir respuestas hay una sección aparte, [listener.*.compression]: gzip y level (1..9, 6 por defecto), brotli y brotli_quality (0..11, 5 por defecto), min_size (1024) y types.

Filtros WASM

Filtro de petición, uno por listener, solo en mode = "http". El módulo se compila al cargar la configuración: un fichero ausente, un import denegado o una exportación que falte rechazan el snapshot, no fallan por petición.

NombreTipoDefectoNotas
pathruta— (obligatorio)Módulo .wasm, relativo al fichero de configuración
fuelentero100000001..=100000000 instrucciones por petición
timeoutduración"50ms"1ms..=5s, con resolución de 1 ms
memory_bytesentero167772161 MiB..=256 MiB de memoria lineal por instancia
max_header_opsentero161..=64 mutaciones de cabecera por petición
on_errorcadena"closed""closed" responde 503; "open" reenvía sin cambios y cuenta el error
[listener.web.plugin]
path = "plugins/filter.wasm"
timeout = "50ms"
on_error = "closed"

El plugin puede añadir, sustituir o quitar cabeceras de petición y rechazar con 400..=599; las de framing (content-length, transfer-encoding, host) están reservadas.

Mapas de datos

NombreTipoDefectoNotas
filerutaausenteUn par clave valor por línea; # solo al inicio; claves duplicadas se rechazan
entriestablaausenteEntradas en línea, estáticas; excluyente con file
defaultcadenaausenteValor para claves sin entrada
reload_intervalduración"5s"Relectura del fichero; "0s" = cargar una vez
[maps.tiers]
file = "tiers.map"
default = "free"

[[listener.web.rules]]
name = "vip-tenants"
map = { name = "tiers", header = "x-tenant", equals = "vip" }
use_backend = "vip"

Un mapa se relee en caliente sin recargar la configuración: los datos nuevos entran de forma atómica y el resto del estado —contadores, cubos, pools, conexiones— sobrevive. Escribe con escritura + renombrado para que el vigilante nunca lea un fichero a medias; una actualización mal formada se rechaza, suma map_reload_failures y sigue sirviendo la última buena.

Secretos y recarga en caliente

SecretoDóndeRecargable con SIGHUP
Certificados y claves TLS[listener.*.tls] y certs[].cert/keySí, y con POST /api/v1/certificates/reload
CA de cliente (mTLS)client_caSí; en un listener QUIC vivo, no
CA hacia el backend[backend.*.tls] ca_fileSí
Token de lectura y de escrituratoken_file, write_token_fileNo: se fijan al arrancar
Modo, grupo, ACL y allowlist del socket Unix[admin] unix_socket_*Sí, sobre el socket ya enlazado
[admin]
unix_socket = "/run/eqproxy/admin.sock"
unix_socket_mode = "0660"
unix_socket_gid = 20
token_file = "/etc/eqproxy/admin.token"
write_token_file = "/etc/eqproxy/admin-write.token"

Prefiere token_file a token: un token escrito en el TOML viaja allí donde se copia el fichero. El token de escritura debe ser distinto del de lectura; sin él, el plano entero es de solo lectura.

Plano de administración

NombreTipoDefectoNotas
listencadenaausenteIP:PORT; loopback salvo allow_remote. Excluyente con unix_socket
unix_socketrutaausenteSocket Unix; modo 0600 por defecto
unix_socket_modecadena"0600"Solo "0600" o "0660"; con 0660 hace falta unix_socket_gid
unix_socket_allow_uidslista[]Uids de par permitidos, comprobados con SO_PEERCRED/getpeereid
unix_socket_acl_uidslista[]Usuarios con rw en la ACL POSIX del fichero; solo Linux, exige 0660 y gid
token / token_filecadena / rutaausenteToken de lectura; al menos uno
write_token / write_token_filecadena / rutaausenteToken de escritura; sin él no hay escrituras
allow_remotebooleanofalsePermite un bind no loopback; el token viaja en claro (el plano no tiene TLS)
panelbooleanofalseSirve el panel web en GET /panel; apagado, todo /panel es 404
[admin]
listen = "127.0.0.1:9001"
token_file = "/etc/eqproxy/admin.token"
write_token_file = "/etc/eqproxy/admin-write.token"
panel = true

Sin sección [admin] no hay plano de administración. Los endpoints, los roles y los códigos de respuesta están en API de administración.