# Integración con Traefik
Ejemplo **ejecutable**: Traefik v3 + este servicio + un backend que devuelve 4xx/5xx.
```sh
docker compose up -d # o: podman-compose up -d
./check.sh # comprueba los bloques de abajo
docker compose down
```
La imagen **se descarga**, no se construye: el compose usa `ghcr.io/atareao/errserve`. Con
`ERRSERVE_IMAGE` puedes apuntar a otra etiqueta o a una imagen local que hayas construido con
`just build`.
Si el `pull` responde `denied`, el paquete de GHCR sigue privado (nace así aunque el repositorio sea
público); se cambia una sola vez a mano, en los ajustes del paquete.
En **podman rootless** hay que apuntar el socket del motor (sin esto, Traefik no ve los
contenedores y todo responde su propio 404):
```sh
CONTAINER_SOCKET=$XDG_RUNTIME_DIR/podman/podman.sock podman-compose up -d
```
## Quién decide el código HTTP
Es el matiz que hay que entender antes de confiar en el servicio. **Depende de cómo esté montado:**
| Middleware `errors` delante de una aplicación | **Traefik**, que conserva el código del upstream | se ignora |
| Acceso **directo**: router *catch-all*, *default backend* | **nosotros** | es el que llega al cliente |
No es una deducción: está medido. El ejemplo incluye un router deliberadamente divergente
(`diverge.localtest.me`) cuyo middleware pide siempre `/404` aunque el backend devuelva `503`; el
cliente recibe **`503`**, o sea, gana Traefik.
El caso que justifica este proyecto es el segundo: en el *catch-all* no hay upstream cuyo código
preservar, así que `error-pages` devuelve `200` y nosotros el código real. El **cuerpo** y las
cabeceras (`Retry-After`, `X-Robots-Tag`, `Content-Type`, negociación por `Accept`) sí son nuestros
en los dos casos.
## Lo que comprueba `check.sh`
**Seis bloques**: los cuatro estados de la tabla, las cabeceras de la página servida del `503`, la
página autocontenida con el tema por petición, y que el respaldo no publique el endpoint de servicio
`/metrics`.
| `app.localtest.me/` | `404` | El backend devuelve 404; el estado lo conserva Traefik, el cuerpo es nuestro. |
| `app.localtest.me/boom` | `503` | Idem con un 503, más `Retry-After: 120` y sus cabeceras (`X-Robots-Tag`, `Content-Type`). |
| `nada.localtest.me/x` | `404` | **Catch-all sin upstream: el código lo decidimos nosotros.** Es la razón del proyecto. |
| `diverge.localtest.me/boom` | `503` | Backend 503 con el middleware pidiendo `/404`: manda Traefik. |
| `app.localtest.me/boom` con `Accept: text/html` | `503` | **El `503` llega con la página del tema —su `<style>` dentro— y sin ningún `<link>`**, y `X-Theme: terminal` cambia el cuerpo de esa petición. |
| `nada.localtest.me/metrics` | no `200` | El respaldo **no** publica el endpoint de servicio `/metrics`. |
`check.sh` sale con estado 1 si algo no cuadra, así que sirve también en un pipeline.
### La página autocontenida y el tema, por petición
Sobre `app.localtest.me/boom` —el **mismo** `503` del backend, no un caso nuevo—, con tres ayudantes
que afirman y niegan sobre el **cuerpo**:
- `html_body app.localtest.me /boom '<style'` — el `503` del backend llega con la página del tema, que
trae su `<style>` **dentro**: la página no pide nada para verse.
- `not_html_body app.localtest.me /boom 'rel="stylesheet"'` — y **no** enlaza ninguna hoja externa: el
enlace a `/css/error.css` **ya no existe**, porque cada página lleva su hoja incrustada. Es el
arreglo de un bug **medido**: con el modelo anterior, en el host de la aplicación ese enlace lo
contestaba el upstream —`404 text/plain`—, el middleware `errors` interceptaba ese `404` y lo volvía
a pedir como página de error con un `Accept` que **no** negocia HTML, la cascada de formato caía al
texto plano y el navegador rechazaba la hoja (*«MIME type ('text/plain') is not a supported
stylesheet MIME type»*). La página salía **sin estilos**, con el `503` y el cuerpo correctos.
- `html_differs app.localtest.me /boom 'X-Theme: terminal'` — el tema se elige **por petición**: pide
el mismo path dos veces, con y sin esa cabecera, y exige que los cuerpos **difieran**. Es lo que
demuestra la selección de extremo a extremo, a través de Traefik; `terminal` es uno de los siete
temas que documenta el [`README.md`](../../README.md) raíz.
Los tres mandan `Accept: text/html` **a propósito**. Sin esa pista, un `curl` manda `*/*`, la cascada
de formato no elige HTML —cae al **texto plano** por defecto— y el bloque no probaría nada: no habría
página con su `<style>` que buscar en el cuerpo. Es también el motivo de que `html_differs` compare
**cuerpos** y no busque un color en la hoja: un color es un detalle que se puede retocar sin avisar, y
«el cuerpo cambia con la cabecera» es el contrato.
### El respaldo no publica el endpoint de servicio
El bloque de `/metrics` pide ese path en `nada.localtest.me` —un Host sin router propio, el caso del
*catch-all*— y exige que la respuesta **no** sea la del endpoint: ni el estado `200`, ni el
`Content-Type` `text/plain; version=0.0.4` que firma a `/metrics`, ni el contador
`errors_pages_total` en el cuerpo. La ausencia de `X-Robots-Tag` es solo una **guarda**, no un
discriminante —`/metrics` es un endpoint de servicio y nunca lleva esa cabecera, así que se cumple en
los dos estados—: está para que nadie «arregle» la fuga convirtiendo `/metrics` en una página de
error. Todo el montaje —y la medición— está explicado en «Operación y observabilidad» del
[`README.md`](../../README.md) raíz y en «Variante sin socket», abajo.
## La trampa de `HostRegexp`
El router de respaldo necesita una regla que case con cualquier host. **La sintaxis cambió en v3 y
la vieja no falla: simplemente no casa.**
```yaml
# MAL en Traefik v3 — sintaxis v2 (placeholder {nombre:regexp})
traefik.http.routers.errserve.rule: HostRegexp(`{host:.+}`)
# BIEN en Traefik v3 — regexp de Go, con la regla completa del respaldo
traefik.http.routers.errserve.rule: HostRegexp(`.+`) && !Path(`/metrics`)
```
Medido en v3.6: ``HostRegexp(`{host:.+}`)`` **no casa con nada** y ``HostRegexp(`.+`)`` casa. Lo
peligroso es cómo se rompe: el router aparece como `enabled` en el dashboard y Traefik responde **su
propio 404 en texto plano**, sin llegar nunca al servicio. Se parece a que todo funciona, y es
exactamente el fallo que este proyecto existe para corregir.
El bloque va de la **sintaxis**, y sólo de eso: el segundo arreglo —``&& !Path(`/metrics`)``— no tiene
nada que ver con v2/v3, es la exclusión del endpoint de servicio que el respaldo necesita (se explica
en «Variante sin socket», abajo, y en «Operación y observabilidad» del `README.md` raíz). La línea
«BIEN» la lleva puesta a propósito, aunque alargue el ejemplo: es la regla desplegable, y una línea
«BIEN» a medias es el vector por el que `/metrics` se filtró de verdad.
## El leak de las páginas de tema
Con el modelo actual, **un tema es una página** y el nombre del tema **es** la ruta de su fichero: las
siete (`default.html`, `minimal.html`, …) viven en la **raíz** de `CONTENT_DIR`. El servicio solo
intercepta **`/default.html`** (y `/default.htm`), que son el **alias del código por defecto**
(`DEFAULT_ERROR_PAGE`); las **otras seis** —`/minimal.html`, `/terminal.html`, `/paper.html`,
`/amber.html`, `/compact.html` y `/matrix.html`— se sirven como **assets crudos** si alguien las pide,
con sus marcadores `{{code}}`/`{{reason}}`/`{{description}}` a la vista.
Es un leak **cosmético**, y con las páginas en la raíz de `CONTENT_DIR` es **inevitable**: el servicio
no las oculta. La mitigación es **externa**, en el proxy, y este repositorio **no** entrega un
respaldo que la implemente (es un no-goal del change `themes-as-pages`). Se expresa añadiendo una
conjunción a la regla del respaldo, que bloquea `*.html` salvo `/default.html`:
```yaml
El precio, escrito: esa conjunción bloquea **también** `/404.html` y `/503.html` por el acceso
directo. No rompe el flujo real —el middleware `errors` pide `/404`, **sin** extensión
(`errors.query: /{status}`)—, pero el acceso humano a `/404.html` deja de servirse. Las páginas de
tema del servicio se deciden **dentro** de este repositorio; el bloqueo, fuera.
## El socket
El ejemplo usa **labels de Docker** porque es el mecanismo que emplea la guía oficial de
`error-pages` y por tanto lo que la gente reconoce. El precio es montar el socket del motor dentro
de Traefik, y eso no es gratis: **quien puede escribir labels puede crear contenedores**. Por eso el
ejemplo fija `--providers.docker.exposedByDefault=false`, que limita la exposición a los contenedores
que llevan `traefik.enable=true`.
### Variante sin socket
Si no quieres montar el socket, el mismo montaje se expresa con el **provider de fichero**, que no
necesita socket y se comporta igual en docker y en podman:
```yaml
# traefik.yaml (estático)
providers:
file:
filename: /etc/traefik/dynamic.yaml
# dynamic.yaml (dinámico)
http:
middlewares:
errpages:
errors:
status: "400-599"
service: errservice
query: "/{status}"
services:
errservice:
loadBalancer:
servers: [{url: "http://errserve:8080"}]
routers:
catchall:
rule: "HostRegexp(`.+`) && !Path(`/metrics`)"
entryPoints: [web]
service: errservice
priority: 1
```
La conjunción `&& !Path(`/metrics`)` es la misma que en los labels, y por el mismo motivo: el respaldo
atiende cualquier Host, y `/metrics` no es una página de error sino un *endpoint de servicio* del
binario, así que publicarlo delata el perfil de fallos de la infraestructura. Con la exclusión, una
petición a `/metrics` en un Host desconocido la contesta Traefik con su propio `404` en texto plano y
el respaldo sigue sirviendo el código real en todo lo demás. Está explicado, con la medición, en el
apartado «Operación y observabilidad» del [`README.md`](../../README.md) raíz.
## Producción
Para un despliegue real, el [`compose.yml`](../../compose.yml) de la raíz trae **solo el servicio**
—imagen, healthcheck, `restart`, variables y los labels de Traefik listos para descomentar— para
encajarlo en la infraestructura que ya tengas. Recuerda que el puerto **interno** es el `8080`.