# errserve
Servidor de páginas de error para **Traefik**, escrito en Rust. Es un clon
deliberadamente mínimo de [tarampampam/error-pages][ep] con **una diferencia de
fondo**:
> `error-pages` responde **siempre HTTP 200**. Este servidor devuelve el
> **código de error real**.
[ep]: https://github.com/tarampampam/error-pages
## Por qué existe
La suposición de `error-pages` (el proxy reescribe solo el cuerpo y conserva el
código del upstream) se cumple con Nginx o ingress-nginx. **No** se cumple con el
patrón de Traefik que documenta su propia guía: un router de respaldo
`HostRegexp(.+)` con prioridad baja, sin upstream cuyo código preservar. Ahí el
`200` del servicio de errores llega tal cual al cliente y una página «404» se
sirve como `200 OK`. Este servicio cierra ese hueco.
## Contrato con Traefik
El middleware `errors` reenvía la petición con la plantilla `errors.query`, así
que **el código viaja en el path** (`/404`, `/503`):
```yaml
traefik.http.middlewares.error-pages-middleware.errors.status: 400-599
traefik.http.middlewares.error-pages-middleware.errors.service: error-pages-service
traefik.http.middlewares.error-pages-middleware.errors.query: /{status}
traefik.http.services.error-pages-service.loadbalancer.server.port: 8080
```
La respuesta lleva el **código resuelto** como estado HTTP. Nunca 200. Y eso vale también para lo
que sirve el servidor de ficheros: cualquier respuesta suya **con estado de error** —no solo su
`404`: también el `405` de un método que no sirve estáticos, el `416` de un `Range` múltiple y el
`500` de un segmento de path que no cabe como nombre de fichero— se sustituye por la página de
error, con **el código resuelto** (path → `X-Code` → `DEFAULT_ERROR_PAGE`), **nunca** con su estado:
el estado del servidor de ficheros no es una fuente de la cascada.
Con el binario anterior se veía el hueco: `GET /<300 caracteres>` respondía `500` **con el cuerpo
vacío** (`content-length: 0`), sin `Content-Type`, sin `X-Robots-Tag` y sin que ninguna página lo
hubiera producido; `PUT /minimal.html` respondía `405` y `Range: bytes=0-1,3-4` sobre un asset
respondía `416`, los dos sin cuerpo y sin pasar por la cascada.
Y hay **tres razones medidas** para no conservar su estado:
- el `405` saldría **sin el `Allow`** que RFC 9110 exige: la página la construimos nosotros y esa
cabecera describe *su* respuesta, no la nuestra;
- el estado y el cuerpo quedarían en desacuerdo —un `404` con `Retry-After: 120` y el cuerpo del
`503`—, contra la regla de que el `404` no lleva `Retry-After`;
- con `DEFAULT_ERROR_PAGE=503`, cualquier asset inexistente saldría `404`, dejando sin efecto a
`X-Code`, que existe justo para los despliegues que no reescriben el path.
Un asset **existente** sigue ganando, que es la otra mitad de la regla: `GET /minimal.html` —una de
las seis páginas de tema que **no** son el alias— es un `200 text/html` crudo, con sus marcadores a la
vista, y no pasa por la página de error. La excepción es `/default.html`: es el **alias del código por
defecto**, así que se intercepta y responde el código resuelto, **nunca `200`** (ver «Los siete temas»).
## Integración con Traefik
Hay un **ejemplo ejecutable** en
[`examples/traefik/`](https://github.com/atareao/errserve/tree/main/examples/traefik/) —Traefik v3 +
este servicio + un backend que devuelve 4xx/5xx— con un `check.sh` que comprueba los cuatro casos, y
un [`compose.yml`](https://github.com/atareao/errserve/blob/main/compose.yml) en la raíz listo para
producción (solo el servicio, con los labels de Traefik para descomentar). El ejemplo explica además
el matiz de quién decide el código HTTP.
Los dos usan la **imagen publicada** (`ghcr.io/atareao/errserve`), así que se levantan sin construir
nada; `ERRSERVE_IMAGE` apunta a otra etiqueta o a una imagen local si hace falta.
Una trampa que conviene conocer antes de copiar una regla de internet: ``HostRegexp(`{host:.+}`)`` es
sintaxis de Traefik **v2** y en v3 **no casa con nada** —el router aparece `enabled` y Traefik
responde su propio 404, sin llegar al servicio—. En v3 es ``HostRegexp(`.+`)``. Es el mismo tipo de
trampa de copia-pega que la del *catch-all*: si copias esa regla, mira «Operación y observabilidad»,
porque además lleva **dos** exclusiones —la de `/metrics` y la del **leak de las páginas de tema**—.
## Ejemplos
```sh
curl -i localhost:8080/404 # 404 Not Found (no 200), con X-Robots-Tag
curl -i localhost:8080/503 # 503 Service Unavailable, con Retry-After: 120
curl -i -H 'X-Code: 503' localhost:8080/ # 503 cuando el path no trae código
curl -s localhost:8080/404.json # {"error":true,"code":404,...}
curl -s -H 'X-Format: application/xml' localhost:8080/404
curl -i localhost:8080/healthz # 200
curl -s localhost:8080/version # {"version":"..."}
curl -s -H 'Accept: text/html' -H 'X-Theme: terminal' localhost:8080/404 # con la página del tema `terminal` dentro
```
## Configuración (solo variables de entorno)
| `PORT` | `8080` | Puerto de escucha (bind `0.0.0.0`). El que asume la guía de Traefik. |
| `LOG_LEVEL` | `info` | Filtro de `tracing-subscriber`. |
| `CONTENT_DIR` | `content` | El directorio del operador **y la fuente de temas**: *override* de la página de un tema incorporado, y los `*.html` de su **raíz** **añaden** temas al registro. |
| `DEFAULT_ERROR_PAGE` | `404` | Código cuando el path y `X-Code` no aportan uno utilizable. |
| `LOG_FORMAT` | `text` | Formato del registro: `text` o `json`. |
| `RETRY_AFTER` | `120` | Segundos de la cabecera `Retry-After`. La llevan solo el `429` y el `503`. |
| `THEME` | `default` | Tema de las páginas **HTML**: uno de los siete **incorporados** (`default`, `minimal`, `terminal`, `paper`, `amber`, `compact`, `matrix`) **o** un tema descubierto en `CONTENT_DIR`. |
No se lee ningún fichero de configuración. Una variable **presente pero vacía**
es un error de arranque, no un valor por defecto. `DEFAULT_ERROR_PAGE` fuera del
rango `400..=599` también lo es, y un `THEME` que no sea un nombre del **registro
de ese arranque** —o que venga vacío— aborta el arranque igual, con un error que
**nombra la variable** y **enumera los nombres del registro**: quien escribe
`THEME` cree que está configurando algo, así que el error tiene que decirle qué
puede escribir en su lugar. Y ojo con la contrapartida: como la lista es la de
**ese** arranque, un `THEME` **correcto** cuyo fichero **todavía** no esté en
`CONTENT_DIR` —un volumen que aún no se ha montado cuando el contenedor arranca—
**aborta** el arranque; la mitigación es del operador: declarar la dependencia del
volumen, o arrancar sin `THEME` y con el defecto.
`RETRY_AFTER` es el único valor de la respuesta que depende de tu infraestructura: la cabecera
`Retry-After` es lo que se le promete al cliente sobre cuándo volver, así que si tu backend tarda
cinco minutos en recuperarse, `120` es una mentira que el cliente obedece. Admite cualquier entero de
segundos —`0` significa «reintenta ya»— y no tiene techo. Una variable vacía, o que no sea un entero
de segundos, es un error de arranque. Lo que **no** se configura es qué códigos la llevan: son el
`429` y el `503`, y eso lo decide el código.
`CONTENT_DIR` es el directorio del operador: el ***override*** de la página de un tema incorporado
**y** la fuente de los temas nuevos. El binario lleva **dentro** las siete páginas de tema, así que
un `cargo install errserve` —que deja un ejecutable y ningún fichero de datos, con `CONTENT_DIR`
apuntando por defecto a un `content` que no existe— sirve las siete, y no el respaldo de texto. Lo
que haya en `CONTENT_DIR` gana **cuando se pueda leer**; lo que falte se sirve desde la copia
embebida. Y los `*.html` de su **raíz** que no sean la página de un incorporado **añaden** temas al
registro: un fichero de más **es** un tema, y su tallo es su nombre (ver «El tema es una página»).
## Cómo se resuelve
**Código** (primero que haga match): path (`/404`, `/404.json`, `/503.xml`…) →
cabecera `X-Code` → `DEFAULT_ERROR_PAGE`. Una fuente con un código fuera del
rango `400..=599` se ignora y se pasa a la siguiente.
**Formato** (primero que haga match): extensión del path (`.html`/`.htm`,
`.json`, `.xml`, `.txt`) → `Content-Type` de la petición → `X-Format` →
`Accept` → **texto plano** por defecto.
Código y formato son ortogonales: `/503.xml` es un 503 en XML.
Y hay un **tercer eje**, que no reparte códigos ni formatos sino aspecto, y que solo toca al HTML: **la
página del tema** que se sirve. La elige `THEME` —el tema de todo el servicio— y, por petición, la
cabecera `X-Theme`, que gana a `THEME` cuando nombra un tema del **registro** —los siete incorporados
más los descubiertos en `CONTENT_DIR`—; un `X-Theme` ausente, desconocido o vacío **no** es un error y
cae al tema configurado. En JSON, XML y texto no hay página que incrustar, así que ahí el tema se
ignora (ver «Páginas autocontenidas y temas»).
Cada página de error lleva `Content-Type`, `Content-Length`,
`X-Robots-Tag: noindex, nofollow, nosnippet, noarchive` y, en `429` y `503`,
`Retry-After` con el valor de `RETRY_AFTER` (`120` por defecto).
El HTML es **la página del tema** elegido: la de `CONTENT_DIR/<nombre>.html` si se puede leer y, si no,
su copia embebida. **No** hay capa por código —`CONTENT_DIR/<código>.html` ya no se consulta—; el
diseño por código vive en la clase `body.c<código>`, que la plantilla rellena al servir. Además,
`/default.html` y `/default.htm` son el **alias del código por defecto** (`DEFAULT_ERROR_PAGE`): se
interceptan y responden ese código, nunca `200`. JSON, XML y texto se generan del código, su razón
canónica y su descripción. Las cuatro formas de un mismo código llevan la **misma** razón y la
**misma** descripción: las tablas de `resolve` son su fuente única.
## Códigos cubiertos
**Cualquier** código de `400..=599` tiene página, no solo once: la página del tema es una **plantilla**
(por defecto `content/default.html`) que el servidor rellena al vuelo, así que un `451`, un `418` o un
`599` salen con **su propio** código, su razón y su descripción, y con **su propio** estado —nunca
verás un `504` servido como `404`, ni un cuerpo que diga `404` para otro código—. Quince códigos traen
su descripción escrita en el código (`400`, `401`, `403`, `404`, `405`, `408`, `410`, `418`, `422`,
`429`, `500`, `501`, `502`, `503` y `504`); el resto usa una genérica, siempre con la razón canónica
del código en el título.
La plantilla lleva tres marcadores que el servidor sustituye **al servir**:
| `{{code}}` | El código resuelto (`404`), allí donde aparezca: el `<title>`, el `<h1>` y la clase del `body` (`class="c{{code}}"`). |
| `{{reason}}` | La razón canónica del código (`Not Found`). |
| `{{description}}` | La descripción del código (la tabla de `resolve`). |
Cada página de tema lleva su `<style>` **dentro**, y la clase del `body` (`c504`, `c503`…) es lo que
permite diferenciar el aspecto de cada código sin duplicar la página: el diseño por código vive ahí,
no en un fichero por código. El tema `matrix` lleva además su `<script>` dentro (ver «Los siete temas»).
### Cómo cambiar el texto de una página de error
Hay tres vías, de la más global a la más local:
1. **El texto de todos los códigos**: edita la tabla de `resolve::description` en `src/resolve.rs` y
recompila. Es la **fuente única** del texto de los cuatro formatos (HTML, JSON, XML y texto), así
que el cambio vale para todos ellos a la vez.
2. **La página del tema**: deja tu fichero en `CONTENT_DIR/<nombre>.html` (por ejemplo,
`content/default.html` para el tema por defecto). Gana a la copia embebida y se lee **al servir**,
así que la ves cambiar sin reiniciar. Si **no** lleva marcadores, se sirve **tal cual**, byte a
byte: su texto fijo es tuyo. Es el **punto de personalización** del operador.
3. **Una página del tema que se adapte al código**: escribe los marcadores `{{code}}`, `{{reason}}` y
`{{description}}` en tu página del tema y el servidor la rellenará con el código resuelto, igual
que hace con la del proyecto. Así una sola página cubre cualquier código `400..=599`.
**Qué pierde el operador, escrito sin maquillarlo**: la capa por **código**
(`CONTENT_DIR/<código>.html`) y la del código por defecto (`CONTENT_DIR/404.html`) **ya no se
consultan**. El punto de personalización es la página del **tema**, así que un `content/404.html` que
tuvieras **deja de servir**: con `THEME=matrix` verás la página de `matrix`, no la tuya. Lo que
conservas: tu página del tema es tuya, gana a la embebida y se sirve byte a byte si no lleva
marcadores. Si quieres granularidad por código, el camino es el `<style>` de tu página, no un fichero
por código.
## Páginas autocontenidas y temas
Cada página de tema es **autocontenida**: lleva su `<style>` **dentro** —y `matrix`, su `<script>`
también—, así que la página no pide ningún fichero para verse. No es una preferencia estética, es el
arreglo de un fallo medido. Con el middleware `errors` de Traefik —el montaje de cualquier aplicación
con dominio propio—, la página se sirve **en el host de la aplicación**. Con el modelo anterior
enlazaba `/css/error.css`, y ese path lo contestaba el upstream, no este servicio: el navegador pedía
la hoja, recibía su `404` con `Content-Type: text/plain`, el middleware interceptaba ese `404` y lo
volvía a pedir como página de error con un `Accept` que no negocia HTML —`text/css,*/*;q=0.1`—, así
que 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»*. El resultado era un `503` correcto, con el
código correcto, y la página **sin estilos**. **Ese enlace ya no existe**: `content/css/error.css` y
el asset `/css/error.css` se retiraron en la `0.8.0` —un tema es una página con su hoja dentro—, así
que no hay `<link>` que servir y el fallo no puede volver.
`error-pages` no lo sufre porque sus páginas son «fully self-contained — all styles and images are
embedded directly in the HTML, without loading any external resources»: esto es **paridad**, no un
adorno.
### El tema es una página
Un tema **es** su página, y el nombre del tema **es** el de su fichero: viven en la **raíz** de
`CONTENT_DIR` —`content/<nombre>.html`—. Las **siete** incorporadas van dentro del binario y la raíz
del directorio **añade** temas: un `*.html` de más **es** un tema, y su **tallo** es su nombre
(`neon.html` → `neon`). El barrido del directorio es **al arrancar**, así que **añadir** un tema exige
**reiniciar**: un fichero añadido con el servicio en marcha **no** es un tema hasta el siguiente
arranque —se sigue sirviendo como **asset crudo**—. Los **bytes** de la página, en cambio, se leen **al
servir**, así que **editar** un tema que ya estaba se ve **sin** reiniciar. Elegir un tema no duplica
nada: elige **qué página** se sirve. Tu página gana a la embebida; una página **sin** marcadores se
sirve **byte a byte**; y una que existe y no se puede leer responde el **respaldo de texto** con el
código resuelto —el `warn` la delata—, sin cambiar el estado. Un tema **descubierto** no tiene copia
embebida: su fichero es su **única** copia, así que si se esfuma responde el respaldo de texto con el
estado intacto.
Las reglas del barrido, para no esperar lo que no es: solo la **raíz** (un `temas/neon.html` no es un
tema); la extensión tiene que ser **exactamente** `html` (`neon.htm`, `neon.HTML` y `neon.css` **no**
son temas); el nombre es el **tallo**; el tallo de un **incorporado** no añade un tema
(`content/default.html` es la página del `default` incorporado, no un `default` de más); y un tallo
que **reclame la resolución de códigos** (`404`, `999`, `200`…) **no** es un tema, porque ese path
responde **otra** página de error. Los descubiertos van **ordenados por nombre** detrás de los siete.
### Los siete temas
Los siete **incorporados** al binario son la **base** del registro, y un directorio puede **añadir**
más (ver «El tema es una página»). Estos siete son:
| `default` | `default.html` | El tema sobrio de la casa, y el **único** de las seis sobrias que anima: un fundido de 180 ms sobre el contenido, que se apaga con `prefers-reduced-motion: reduce`. Sus reglas pueden cambiar de una versión a otra; lo que no cambia es que sigue siendo el tema por defecto. |
| `minimal` | `minimal.html` | Solo tipografía, sin acento de color; claro u oscuro según decida el sistema. |
| `terminal` | `terminal.html` | Monoespaciado, fósforo verde sobre negro. No se mueve: un terminal apagado, sin parpadeo ni cursor. |
| `paper` | `paper.html` | Serif, fondo crema, aire de página impresa. |
| `amber` | `amber.html` | Monocromo ámbar, tipo CRT. |
| `compact` | `compact.html` | Todo lo corto posible, con el título y el subtítulo en una línea: para móvil y CLI. |
| `matrix` | `matrix.html` | Un **documento propio**: canvas de lluvia, glitch, scanlines, terminal, pastillas roja y azul, minijuego de teclado y toast. Su `<script>` consulta `prefers-reduced-motion` y no arranca la lluvia con movimiento reducido. |
`THEME` (entorno, al arrancar) elige el tema de **todo el servicio**, y `X-Theme` (cabecera de la
petición) elige el de **esa** petición, con el primer-origen-que-haga-match de siempre: un `X-Theme`
**conocido** —un tema del registro— gana a `THEME`. Un `X-Theme` desconocido **no** es un error —cae al
tema configurado—, porque esa cabecera la elige quien llama y un cliente no puede romper el servicio;
la comparación es **exacta**, así que `X-Theme: Terminal` no es el tema `terminal` y cae al
configurado. `X-Theme` solo afecta al **HTML**: un JSON o un XML no tienen página que incrustar, así
que ahí el tema se ignora.
Ninguna de las siete referencia recursos externos —ni `@import`, ni `url(...)` remotos, ni fuentes
descargadas—: una página que necesita la red para verse es exactamente el defecto que este apartado
corrige. Las seis sobrias comparten su marcado **palabra por palabra** —quitado el bloque `<style>`—
y solo cambia su CSS; lo vigila una prueba de `tests/content_pages.rs`, no una guarda —una guarda
textual no puede demostrar que seis ficheros no diverjan—. Y como viven en la raíz de `CONTENT_DIR`,
las **otras seis** páginas (todas menos `default.html`) se sirven crudas si alguien las pide: el
**servicio** sigue sin ocultarlas —es un servidor, no un proxy, y esa mitad se mantiene—, pero este
repositorio **ya entrega la regla del respaldo que las bloquea** —y un tema **descubierto** nuevo queda
protegido **sin añadirlo a ninguna lista**, porque la regla bloquea **por forma**, no por nombre—, y
una prueba la guarda (`tests/traefik_catchall.rs`, porque comprobarlo de extremo a extremo necesita un
motor de contenedores y no corre en el CI). La regla, su precio y sus límites están en «Operación y
observabilidad»: aquí, la frase y el puntero.
Una página de tema **puede animar**: el movimiento es una decisión de aspecto, no un defecto de
autocontención. Ahora bien, lo que anima tiene que poder apagarse: una página que declare una animación
—`animation`, `animation-name`, `transition` o `transition-property` con un valor **distinto de
`none`**— tiene que **neutralizarla** dentro de un bloque `@media (prefers-reduced-motion: reduce)` que
**exista**, **no esté vacío** y apague **cada familia** con la forma canónica —`animation: none` o
`animation-name: none` para una animación, `transition: none` o `transition-property: none` para una
transición; `animation: none` **no** apaga una transición—. `@keyframes` por sí solo no obliga a nada:
define fotogramas y no anima mientras ninguna declaración los use. Un guion que anime con
`requestAnimationFrame` o `setInterval` tiene que **mencionar** `prefers-reduced-motion`. Y el motivo
sigue siendo el mismo: hay gente a la que el movimiento le marea, y una página de error que se mueve
mientras intentas leer qué ha pasado es peor página de error.
Las páginas **incorporadas** cumplen **tres contratos** —no pedir nada por la red, poder apagar el
movimiento y, las seis sobrias, cubrir su marcado (`body`, `h1`, `h2`, `hr`, `p` y `footer`)— y las
pruebas del proyecto los comprueban. Los dos primeros son comprobaciones **textuales sobre los bytes**
de la página: garantizan que el patrón canónico está, **no** que el CSS apague el movimiento en todos
los navegadores —eso exigiría un motor de CSS y una matriz de navegadores—; y la guarda de red **no
puede demostrar** que un guion construya una URL en tiempo de ejecución, ni que una librería pida por
su cuenta. La guarda de movimiento mide la neutralización **por familia, no por declaración**: ligar
cada declaración con *su* neutralización exigiría un parser de selectores y rechazaría
neutralizaciones legítimas (`* { animation: none }`), así que dos animaciones declaradas y **una sola**
neutralizada **pasan**; el límite está escrito en vez de escondido. Y todo esto cubre las **siete
páginas que el proyecto entrega**, no la tuya: una página propia en `CONTENT_DIR/<nombre>.html` es tuya
y el servicio no la inspecciona al servir. `url(#…)` —un filtro o degradado SVG—, `url(data:…)` y
`@font-face` con `src: local(...)` están **permitidos**, porque no piden nada a la red.
### El endurecimiento y la CSP
Las siete páginas son **incompatibles con una CSP estricta** (`default-src 'none'`): cada una lleva su
`<style>` **en línea** y `matrix` su `<script>` **en línea**. Si el proxy pone una CSP sin
`style-src 'unsafe-inline'` —o sin *nonce*—, las siete pierden el estilo; y `matrix` pierde también el
script, y con él el teclado del minijuego y las pastillas. El servicio **no emite CSP**: la pone el
proxy, que es donde va el endurecimiento. Es una **decisión de diseño** escrita, no un fallo.
`THEME` es el equivalente del `--template-name`/`$TEMPLATE_NAME` de `error-pages` —que por defecto usa
`app-down`—, con una diferencia que se declara: allí la plantilla puede **rotar**
(`--rotation-mode`) y aquí no (ver «No incluye»).
## Quién decide el código HTTP
Depende de cómo esté montado el servicio detrás de Traefik, y conviene saberlo antes de confiar en
él:
| Middleware `errors` sobre 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 |
Medido, no deducido: con el middleware interceptando, una configuración divergente —backend `503`
con el middleware pidiendo `/404`— hizo llegar al cliente un `503`. El caso que justifica este
proyecto es el segundo: ahí `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.
## Operación y observabilidad
`GET /metrics` expone en texto Prometheus el contador de páginas servidas, con **dos** etiquetas:
```
errors_pages_total{code="404",source="path"} 3
errors_pages_total{code="503",source="header"} 1
```
`code` es el código servido y `source` dice de dónde salió ese código: `path`, `header` (`X-Code`)
o `default` (`DEFAULT_ERROR_PAGE`). La etiqueta hace falta porque `X-Code` la elige quien llama: sin
ella, cualquiera puede inflar a voluntad el contador de un código y fabricar la apariencia de una
avalancha justo durante una incidencia; con ella, ese inflado se ve al leer el scrape.
**No publiques `/metrics` en el router de respaldo.** Es un endpoint de servicio —siempre `200`,
fuera de la resolución de páginas de error— y no una página de error: expone la actividad del propio
servicio (qué códigos ha servido, que es el perfil de fallos de tu infraestructura), así que déjalo
fuera del *catch-all* o ponle delante un middleware de Traefik (*basicAuth*, una `IPAllowList`). La
aplicación **no** autentica nada: el sitio para eso es el proxy. Y dejarlo fuera del *catch-all* no
es un detalle de la prosa: son **dos** conjunciones en la regla —la de `/metrics` y la que cierra el
**leak de las páginas de tema**—, y las llevan las **cinco** definiciones de respaldo desplegable que
entrega este repositorio, en **cuatro** ficheros: el ejemplo de `examples/traefik/` (sus labels), la
plantilla de producción de `compose.yml`, este mismo `README.md` y las **dos** del README del ejemplo
—el bloque de prosa y la variante de *provider de fichero*—. La segunda no es cosmética: el nombre de
cada tema **es** la ruta de su página, así que sin ella el respaldo publicaba los paths de las seis
páginas de tema que **no** son el alias del código por defecto, con `200` y los marcadores
`{{code}}`/`{{reason}}`/`{{description}}` a la vista, a quien preguntara por un Host sin router
propio.
```yaml
`tests/traefik_catchall.rs` las guarda, porque la comprobación de extremo a extremo necesita un motor
de contenedores y no corre en el CI: sin esa prueba, la fuga solo se descubriría volviendo a
desplegar. Y quitar la de `/metrics` no rompe el respaldo: una petición a `/metrics` no casa con
ningún router, Traefik responde su propio `404` —19 B, texto plano, sin `X-Robots-Tag`— y el servicio
ni se entera, mientras que para cualquier otro path el respaldo sigue sirviendo el código real
(`/` → `404` con la firma del servicio, `/503` → `503` con su `Retry-After`).
`/version` sí se queda a la vista **a propósito**, por paridad con `error-pages`: publica la versión
y nada más, y hoy no hay ningún aviso que lo haga explotable.
La respuesta se comprime cuando el cliente envía `Accept-Encoding: gzip` —y entonces responde
con codificación por trozos, sin `Content-Length`—, y declara `Vary: Accept-Encoding, X-Request-Id,
X-Trace-Id, X-Correlation-Id, X-Amzn-Trace-Id, X-Theme`: las seis, siempre, las traiga la petición o
no. Las cabeceras de correlación se copian de la petición a la respuesta cuando vienen —no se
inventan—, así que una caché intermedia tiene que saber que la respuesta varía por ellas; y `X-Theme`
cambia **el cuerpo** —la página del tema que va incrustada en el HTML—, así que sin declararla una caché le
entregaría a un cliente el tema que pidió otro, o el cuerpo entero de otro. Sin caché por medio el
impacto es nulo (Traefik no cachea por defecto), pero con un CDN delante no.
### El registro de peticiones
El servicio ya traza cada petición —método, ruta, latencia y estado—, pero con el `LOG_LEVEL` por
defecto (`info`) no se ve: lo único que sale es la línea de arranque. Para encenderlo:
```sh
LOG_LEVEL="tower_http=debug,info" # dos líneas por petición: entrada y salida
LOG_LEVEL="tower_http::trace::on_failure=off,tower_http=debug,info" # sin el ruido de los 5xx
```
La segunda forma es la recomendable, y el motivo está en el propio diseño del servicio: el
clasificador de `tower-http` marca **todo `5xx` como fallo**, y como este servicio *sirve* páginas
`5xx`, cada `503` normal fingiría un error. Por eso su nivel bajó a `DEBUG`; al encender el acceso
conviene silenciarlo, o volverá el ruido por la puerta de atrás. Con `LOG_FORMAT=json` cada suceso
sale en una línea de JSON —las de salida, con `latency` y `status`—, listo para un colector sin
expresiones regulares.
Lo que el registro **no** trae: la dirección del cliente ni el `X-Code` recibido. Para saber quién
está inflando el contador de un código, esa información ya está en `/metrics`, en la etiqueta
`source`.
## Imagen
La imagen publicada está en **GHCR**, no en Docker Hub:
```sh
docker pull ghcr.io/atareao/errserve:latest # o una versión fija: :0.3.0
```
El [`compose.yml`](https://github.com/atareao/errserve/blob/main/compose.yml) de la raíz y el
ejemplo de [`examples/traefik/`](https://github.com/atareao/errserve/tree/main/examples/traefik/) la
usan por defecto (`ERRSERVE_IMAGE` cambia la referencia), así que **no hay que construir nada** para
probarlos. Si el `pull` responde `denied` sin credenciales, el paquete sigue privado: ver
[Publicación](#publicación).
Es multi-stage, con enlace estático a musl, usuario no-root (uid 10001) y ~12 MB.
Para construirla en local (docker por defecto; `just engine=podman` para podman):
```sh
just build # imagen, con la versión del último tag de git y `latest`
just run # contenedor en 8080
just dev # cargo run en el host, leyendo content/
just smoke # construye y corre el smoke test contra la imagen, con el motor de esta máquina
just login # entra en GHCR (una vez por máquina)
just push # publica a mano la versión y `latest`
```
`just smoke` invoca el mismo script que los dos workflows (`.github/scripts/smoke.sh`), con el motor
de esta máquina; `PORT=… just smoke` cambia el puerto de loopback si el `8080` está ocupado.
## CHANGELOG
Se mantiene con [`git-cliff`](https://git-cliff.org) a partir de los commits convencionales:
```sh
just changelog # antepone la sección de lo que aún no está etiquetado
just changelog v0.2.0 # lo mismo, ya con el nombre del tag que se va a crear
```
Antepone, no regenera: la entrada de `0.1.0` está escrita a mano y el fichero no se pierde.
## Dependencias
`cargo deny check -D warnings` (con `deny.toml`) revisa avisos de seguridad, licencias, duplicaciones
y fuentes. Corre en el CI, en el job `cargo-deny`:
```sh
cargo deny check -D warnings
```
## Publicación
Se publica **con una etiqueta**:
```sh
git tag v0.2.0 && git push origin v0.2.0
```
Eso dispara
[`.github/workflows/release.yml`](https://github.com/atareao/errserve/blob/main/.github/workflows/release.yml),
que construye la imagen, la **prueba** y la empuja a GHCR con su versión y `latest` y crea el Release
con las notas generadas. Las aserciones sobre la imagen arrancada viven en **un solo artefacto
versionado**,
[`.github/scripts/smoke.sh`](https://github.com/atareao/errserve/blob/main/.github/scripts/smoke.sh),
y lo invocan **las dos** puertas: el CI en cada propuesta de cambio y el release **antes** de publicar
nada. El motivo es una lección medida: en la `v0.8.0` la aserción `GET /css/error.css -> 200` vivía en
un único `run:` del `release.yml`, y **dos copias de la misma lista divergen** —el cambio del contrato
no pasó por ahí y el fallo apareció al etiquetar—. El `HEALTHCHECK` y el `USER` ya **no** se
ejercitan solo aquí, y el **`502`** entra en la lista —el caso por el que existe el proyecto, que
hasta ahora **ninguna** puerta nombraba—. El `HEALTHCHECK` solo se puede **exigir** cuando la imagen
lo declara: `podman` construye en formato OCI y lo descarta, así que el script lo dice en voz alta y
**no** lo cuenta como fallo. Un `workflow_dispatch` republica una etiqueta ya existente, pero exige
indicarla en el input `tag`: en un dispatch no hay etiqueta y `github.ref_name` es la rama.
`main` y `development` están **protegidas**: todo entra por **PR**, los **tres** checks requeridos
—**`fmt, clippy y tests`**, **`cargo-deny`** e **`imagen`**— están marcados como requeridos en las
**dos** ramas con `strict` (la rama ha de estar al día) y `enforce_admins` está activo, así que
**tampoco el dueño** empuja directo: ningún PR se fusiona hasta que los tres están en verde. Y el job
`imagen` **construye la imagen y la prueba** (`bash .github/scripts/smoke.sh`), porque construir no es
probar. El release no sustituye a esos checks.
### La visibilidad del paquete
En GitHub **no se puede cambiar la visibilidad de un paquete por API**: el
`PATCH /user/packages/{type}/{name}` que circula por ahí ya no existe (medido: responde `404`, no está
en el OpenAPI de GitHub y en GraphQL no hay mutación de paquetes), así que solo se toca a mano en los
ajustes del paquete.
En este repositorio, **medido tras la primera publicación**, el paquete quedó **público**: la
descarga anónima funciona. Si algún día `docker pull ghcr.io/atareao/errserve` responde `denied`, es
que el paquete quedó privado: se arregla una sola vez en
<https://github.com/users/atareao/packages/container/errserve/settings> » *Danger Zone* » *Change
visibility* » **Public**.
## Desarrollo
```sh
cargo run
cargo test
cargo fmt --check && cargo clippy -- -D warnings && cargo test # antes de commitear
just smoke # smoke test de la imagen, antes de commitear
```
El repositorio compila **sin conexión**: las dependencias se resuelven contra la
caché local de Cargo (`cargo check --offline`).
## No incluye (a propósito)
Sin motor de plantillas —solo la sustitución literal de los tres marcadores de la
página—, sin localización, sin generador estático y sin TLS (lo termina Traefik).
Se copian **solo** las cuatro cabeceras de correlación, no el conjunto completo de
`--proxy-headers`, y la compresión se limita a gzip.
Tampoco hay rotación de temas: `error-pages` tiene `--rotation-mode` (aleatorio
al arrancar, horario, diario) y aquí no existe —un tema, y ya—. Los **siete** temas
siguen **incorporados al binario** —un `cargo install` sirve los siete sin ficheros de datos—, **y**
el directorio `CONTENT_DIR` **añade** nombres: un `*.html` de más en su raíz **es** un tema nuevo (ver
«El tema es una página»). `THEME` **sí** depende del sistema de ficheros al arrancar: se valida contra
el registro de **ese** arranque, y un `THEME` correcto cuyo fichero todavía no esté montado **aborta**.
Es una decisión declarada, con su precio: el barrido es al arrancar, así que **añadir** un tema exige
**reiniciar**.
## Licencia
MIT.