errserve 0.9.0

Servidor de páginas de error para Traefik que devuelve el código de error real
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
# 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)

| Variable | Defecto | Para qué |
|---|---|---|
| `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**:

| Marcador | Se sustituye por |
|---|---|
| `{{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:

| Tema | Página en `CONTENT_DIR` | Aspecto |
|---|---|---|
| `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:

| Montaje | Quién decide el estado | Nuestro código |
|---|---|---|
| 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
traefik.http.routers.errserve-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`) && (!PathRegexp(`\.html?((%2[fF])|/|$)`) || Path(`/default.html`) || Path(`/default.htm`))
```

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