errserve 0.8.0

Servidor de páginas de error para Traefik que devuelve el código de error real
# 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:**

| Caso | Quién pone el estado | Nuestro código |
|---|---|---|
| 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`.

| Petición | Esperado | Qué demuestra |
|---|---|---|
| `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
traefik.http.routers.errserve-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`) && (!PathRegexp(`\.html$`) || Path(`/default.html`))
```

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`.