ExyonQ Servidor HTTP modular y reverse proxy en Rust

Ejemplos

Recetario de configuraciones completas. Todas se han validado con exyonq validate -c <fichero> sobre el binario 0.9.0: son ficheros enteros, no fragmentos. Ajusta rutas y dominios a tu máquina antes de arrancar.

(a) Sitio estático con caché por MIME

Sirve el sitio y, aparte, los assets versionados con un año de caché e immutable. El HTML se queda fuera de ese prefijo y conserva ETag y revalidación.

config_version = 2

[logging]
level = "info"
format = "text"

[logging.console]
enabled = true
stream = "stdout"

[logging.access]
enabled = true

[modules.compression]
enabled = true
min_bytes = 256
level = 5

[[server]]
listen = "0.0.0.0:8080"
server_name = ["exyonq.dev", "www.exyonq.dev"]
routes = ["site", "assets"]

[server.error_pages]
"404" = "/404.html"

[[route]]
name = "site"
match = { path = "/", host = "exyonq.dev" }
root = "/var/www/exyonq-dev"
index = "index.html"
response_headers = [{ name = "X-Content-Type-Options", value = "nosniff", always = true }]

[[route]]
name = "assets"
match = { path = "/assets", host = "exyonq.dev" }
root = "/var/www/exyonq-dev/assets"

[route.cache_headers.by_mime]
"image/avif" = { max_age = 31536000, immutable = true }
"image/webp" = { max_age = 31536000, immutable = true }
"text/css" = { max_age = 86400 }

root en una ruta es un alias: /assets/logo.png se resuelve a <root>/logo.png. La cabecera Cache-Control se calcula con el MIME de la respuesta; content_type con ; charset=… también casa.

(b) Reverse proxy a un backend HTTP

Cabeceras de confianza hacia el backend, incluida Connection derivada de un map, y reescritura de Location para que el cliente no vea la dirección interna.

config_version = 2

[[server]]
listen = "0.0.0.0:8080"
routes = ["api"]

[[upstream]]
name = "backend"
target = "http://127.0.0.1:9000"
timeout_ms = 10000

[[map]]
name = "conn_upgrade"
input = "header"
header = "Upgrade"
default = "close"
values = { "websocket" = "upgrade" }

[[route]]
name = "api"
match = { path = "/api", host = "api.exyonq.dev" }
upstream = "backend"
proxy_set_headers = [
  { name = "X-Real-IP", value = "$remote_addr" },
  { name = "Host", value = "$host" },
  { name = "X-Forwarded-Proto", value = "$scheme" },
  { name = "Connection", value = "$conn_upgrade" },
]
location_rewrite = [{ from = "http://127.0.0.1:9000/", to = "/api/" }]

$remote_addr es la IP del par del socket. Para confiar en X-Forwarded-For hace falta [modules.real_ip] con una lista de proxies de confianza.

(c) WordPress con nginx + PHP-FPM

Dos formas válidas. La primera mantiene nginx delante de PHP-FPM y ExyonQ solo termina TLS y hace proxy; es la que usa el despliegue de referencia. La segunda habla FastCGI directamente con PHP-FPM.

c.1 Proxy a nginx

config_version = 2

[[server]]
listen = "0.0.0.0:8080"
server_name = ["blog.exyonq.dev"]
routes = ["blog"]

[[upstream]]
name = "blog_backend"
target = "http://127.0.0.1:8090"
timeout_ms = 120000

[[route]]
name = "blog"
match = { path = "/", host = "blog.exyonq.dev" }
upstream = "blog_backend"

El timeout_ms alto es deliberado: una actualización del núcleo de WordPress no debe cortarse a los 30 s.

c.2 FastCGI directo a PHP-FPM

config_version = 2

[[server]]
listen = "0.0.0.0:8080"
routes = ["wp-includes", "wp-content", "wordpress"]

[[route]]
name = "wp-includes"
match = { path = "/wp-includes" }
root = "/var/www/html/wp-includes"

[[route]]
name = "wp-content"
match = { path = "/wp-content" }
root = "/var/www/html/wp-content"

[[route]]
name = "wordpress"
match = { path = "/" }
fastcgi = "php"
htaccess = "overlay"

[[fcgi_pool]]
name = "php"
address = "/run/php/php-fpm.sock"
transport = "unix"
document_root = "/var/www/html"
max_concurrency = 16
idle_timeout_ms = 30000
total_timeout_ms = 120000
checkout_timeout_ms = 5000

Con htaccess = "overlay" ExyonQ aplica el .htaccess del árbol como capa adicional. La raíz documental es obligatoria y la ruta no puede ser a la vez proxy o redirección.

(d) HTTP a HTTPS por host

El proceso del :80 no tiene contenido: una ruta de redirección por nombre, más un catch-all para lo que no case. location se emite tal cual, sin conservar la ruta solicitada.

config_version = 2

[[server]]
listen = "0.0.0.0:80"
routes = ["to_https_exyonq", "to_https_www", "to_https"]

[[route]]
name = "to_https_exyonq"
match = { path = "/", host = "exyonq.dev" }
redirect = { status = 301, location = "https://exyonq.dev" }

[[route]]
name = "to_https_www"
match = { path = "/", host = "www.exyonq.dev" }
redirect = { status = 301, location = "https://www.exyonq.dev" }

[[route]]
name = "to_https"
match = { path = "/" }
redirect = { status = 301, location = "https://exyonq.dev" }

(e) Multi-dominio con un solo certificado y WAF en block

Un certificado con varios nombres, un listener, WAF bloqueando firma y limitando abuso.

config_version = 2

[[server]]
listen = "0.0.0.0:443"
server_name = ["exyonq.dev", "www.exyonq.dev", "eqproxy.dev", "www.eqproxy.dev"]
routes = ["dev", "eqproxy"]

[server.tls]
cert = "/etc/exyonq/tls/exyonq.pem"
key = "/etc/exyonq/tls/exyonq.key"

[server.tls.acme]
enabled = true
email = "security@exyonq.dev"
domains = ["exyonq.dev", "www.exyonq.dev", "eqproxy.dev", "www.eqproxy.dev"]
challenge = "http-01"

[[route]]
name = "dev"
match = { path = "/", host = "exyonq.dev" }
root = "/var/www/exyonq-dev"
index = "index.html"

[[route]]
name = "eqproxy"
match = { path = "/", host = "eqproxy.dev" }
root = "/var/www/eqproxy-dev"
index = "index.html"

[waf]
enabled = true
mode = "block"
engine = "native"
max_inspection_body_bytes = 1048576
on_inspection_limit = "log_and_allow"
fail_policy = "closed_for_invalid_rules"

[waf.abuse]
mode = "rate_limit"
requests_per_second = 20
burst = 40

[[waf.ruleset]]
id = "exyonq-core"
enabled = true

[waf.ruleset.builtins]
sql_injection = "block"
xss = "block"
path_traversal = "block"
command_injection = "block"
header_anomaly = "log"
bot_ua = "block"

Un certificado para cuatro nombres exige que la CA los haya validado todos: con HTTP-01 cada nombre debe resolver al servidor; con comodín (*.exyonq.dev) hace falta challenge = "dns-01".

(f) Redirecciones y rewrites

Redirección externa, reescritura literal, reescritura con captura y una ruta por sufijo.

config_version = 2

[[server]]
listen = "0.0.0.0:8080"
routes = ["old-blog", "spa", "clean-urls", "post"]

[[route]]
name = "old-blog"
match = { path = "/blog" }
redirect = { status = 301, location = "https://exyonq.dev/news" }

[[route]]
name = "spa"
match = { path = "/app" }
rewrite = "/index.html"

[[route]]
name = "clean-urls"
match = { path = "/p" }
rewrite = { pattern = "^/p/([0-9]+)$", replacement = "/post.php?id=$1", flag = "last" }

[[route]]
name = "post"
match = { suffix = ".php" }
root = "/var/www/app"

rewrite literal usa semántica last: reencamina una vez. En la forma con tabla, flag puede ser last, break, redirect o permanent, y when añade condiciones AND por path, host, query, header y header_value. El destino de last/break debe ser una ruta absoluta, sin //, ? ni #.

(g) Varios upstreams con health checks

Balanceo entre tres nodos con bandas de prioridad, más un segundo backend con afinidad por cookie y comprobación de salud propia.

config_version = 2

[[server]]
listen = "0.0.0.0:8080"
routes = ["app"]

[modules.real_ip]
enabled = true
trusted = ["10.0.0.0/8"]
header = "x-forwarded-for"

[[upstream]]
name = "app"
selection_policy = "least_conn"
failover_policy = "priority_bands"
timeout_ms = 15000
endpoints = [
  { address = "10.0.1.11", port = 8080, weight = 3, priority = 0 },
  { address = "10.0.1.12", port = 8080, weight = 3, priority = 0 },
  { address = "10.0.2.21", port = 8080, weight = 1, priority = 1 },
]

[upstream.health_check]
enabled = true
interval_ms = 5000
timeout_ms = 1000
path = "/health"
healthy_threshold = 2
unhealthy_threshold = 3
expect_status = [200]
expect_body = "ok"

[[upstream]]
name = "api"
target = "http://127.0.0.1:9001"
timeout_ms = 5000
selection_policy = "cookie_affinity"
sticky_cookie = "APP_ROUTE"

[upstream.health_check]
enabled = true
interval_ms = 10000
timeout_ms = 2000

[[route]]
name = "app"
match = { path = "/", host = "app.exyonq.dev" }
upstream = "app"
cache = "html"

[[cache_policy]]
name = "html"
ttl_seconds = 60
max_object_bytes = 1048576

Con priority_bands, el tráfico solo baja a la banda 1 cuando la 0 no tiene nodos elegibles. Un endpoint con weight = 0 o admin_state = "disabled" no recibe tráfico. timeout_ms del health check debe ser menor o igual que interval_ms, o la configuración se rechaza.