errserve 0.10.0

Servidor de páginas de error para Traefik que devuelve el código de error real
# =============================================================================
# Ejemplo ejecutable: Traefik v3 + `errserve` + un backend que devuelve 4xx/5xx.
#
#   cd examples/traefik
#   podman-compose up -d          # o: docker compose up -d
#   ./check.sh                    # los tres curl de la tabla del README
#   podman-compose down           # o: docker compose down
#
# POR QUÉ LABELS DE DOCKER Y NO UN FICHERO DINÁMICO
#   * Es el mecanismo que usa la guía oficial de `tarampampam/error-pages` para
#     Traefik, así que quien llega desde allí reconoce la configuración tal cual.
#   * Se verificó aquí **funcionando con podman**: el proveedor `docker` de
#     Traefik habla con la API de podman (compatible) a través de su socket.
#   * El precio es montar el socket del motor en el contenedor de Traefik. En
#     este ejemplo es aceptable (quien puede escribir labels puede crear
#     contenedores), pero NO es gratis: ver "El socket" en README.md. Si eso no
#     te vale, el README documenta la variante con fichero dinámico, que no
#     necesita socket y es idéntica en docker y en podman.
#
# El socket se monta con `CONTAINER_SOCKET`, cuyo valor por defecto es el de
# docker. En podman rootless hay que apuntarlo al del usuario:
#   CONTAINER_SOCKET=$XDG_RUNTIME_DIR/podman/podman.sock podman-compose up -d
#
# Este compose NO construye la imagen: descarga `ghcr.io/atareao/errserve` (el
# paquete publicado). `ERRSERVE_IMAGE=<referencia>` cambia eso, por ejemplo para
# probar una etiqueta concreta o una imagen local hecha con `just build`.
# =============================================================================

name: errserve-traefik-example

services:
  # ---------------------------------------------------------------------------
  # Traefik: quien decide el estado HTTP cuando el middleware `errors` está
  # delante de una aplicación (ver README.md).
  # ---------------------------------------------------------------------------
  traefik:
    image: traefik:v3.6
    container_name: errserve-demo-traefik
    restart: unless-stopped
    command:
      - --entrypoints.web.address=:80
      # El proveedor por labels. `exposedByDefault=false` es importante: en esta
      # máquina hay más contenedores en el motor y sin esto Traefik publicaría
      # routers para todos. Solo entra el que lleva `traefik.enable=true`.
      - --providers.docker=true
      - --providers.docker.exposedByDefault=false
      - --providers.docker.endpoint=unix:///var/run/docker.sock
      - --ping=true
      - --log.level=INFO
      - --accesslog=true
    ports:
      # Puerto alto en el host para no chocar con nada; dentro sigue siendo :80,
      # que es el entrypoint `web`. Traefik no es el servicio que se publica: es
      # el que publica a los demás.
      - "8474:80"
    volumes:
      - ${CONTAINER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:z
    healthcheck:
      test: ["CMD", "traefik", "healthcheck", "--ping"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 5s
    networks:
      - net

  # ---------------------------------------------------------------------------
  # El servicio del proyecto. Es el que responde las páginas con el código real.
  # ---------------------------------------------------------------------------
  errserve:
    # La imagen publicada, en GHCR: se descarga sola, no hay que construir nada.
    # Con `ERRSERVE_IMAGE` se prueba otra etiqueta o una imagen local
    # (`just build`), p. ej. `ERRSERVE_IMAGE=ghcr.io/atareao/errserve:0.3.0`.
    image: ${ERRSERVE_IMAGE:-ghcr.io/atareao/errserve:latest}
    container_name: errserve-demo-errors
    restart: unless-stopped
    environment:
      # Los mismos valores por defecto que trae la imagen; explícitos para que
      # el ejemplo se lea de un vistazo.
      PORT: "8080"
      LOG_LEVEL: "info"
      LOG_FORMAT: "text"
      CONTENT_DIR: "content"
      DEFAULT_ERROR_PAGE: "404"
    networks:
      - net
    labels:
      traefik.enable: "true"

      # --- Servicio ----------------------------------------------------------
      # El puerto es el INTERNO del contenedor (8080), no uno del host: Traefik
      # habla con el contenedor por la red del compose y no hace falta publicar
      # nada con `ports:`.
      traefik.http.services.errserve.loadbalancer.server.port: "8080"

      # --- Middleware `errors` -----------------------------------------------
      # Intercepta cualquier respuesta 4xx/5xx del router al que se aplique y
      # pide el cuerpo a `errserve` con la plantilla de `query`. `/{status}`
      # es lo que produce la petición `/404`, que es donde `errserve` lee el
      # código: el contrato del proyecto depende de esa plantilla.
      traefik.http.middlewares.errpages.errors.status: "400-599"
      traefik.http.middlewares.errpages.errors.service: errserve
      traefik.http.middlewares.errpages.errors.query: "/{status}"

      # --- Router de respaldo (catch-all) ------------------------------------
      # Un Host que no case con ningún otro router cae aquí, NO tiene upstream y
      # su código de error no lo puede conservar nadie: lo decide `errserve`. Es
      # el caso que justifica este proyecto (con `error-pages` sería un 200).
      #
      # La prioridad se pone a mano y baja: Traefik, sin prioridad explícita,
      # ordena por longitud de la regla, y `HostRegexp(...)` es más larga que
      # `Host(\`app.localtest.me\`)`, así que el catch-all ganaría a todo.
      #
      # OJO con la regla. La guía de error-pages y mil copias por ahí usan
      # `HostRegexp(\`{host:.+}\`)`: es sintaxis de Traefik v2 (grupos con
      # nombre al estilo gorilla/mux) y en v3 **no casa con nada** — Traefik
      # responde su propio 404 en texto plano y nunca se llega al servicio de
      # errores, que es justo el fallo que este proyecto existe para evitar.
      # En v3 los matchers `*Regexp` usan la sintaxis de Go (ver
      # doc.traefik.io/traefik/migrate/v2-to-v3-details/), así que va `.+\`
      # a secas. Medido con v3.6: con `{host:.+}` el router está `enabled` pero
      # no coincide con ninguna petición; con `.+\` coincide con todas.
      # (Equivalente al gusto de v3: `PathPrefix(\`/\`)`.)
      #
      # Y la regla no es sólo `HostRegexp`: lleva una segunda conjunción,
      # `!Path(\`/metrics\`)`, que no es cosmética. El respaldo atiende
      # **cualquier** Host, así que sin ella publica también los *endpoints de
      # servicio* del binario —y `/metrics` no es una página de error: es un
      # endpoint de servicio, siempre `200`, que expone `errors_pages_total`, o
      # sea el perfil de fallos de la infraestructura— a quien pregunte por un
      # Host que no exista. Medido: sin la exclusión, `curl -H 'Host:
      # nada.localtest.me' .../metrics` devolvía `200` con el contador; con ella,
      # esa misma petición recibe el `404` de Traefik —texto plano, 19 B, sin
      # `X-Robots-Tag`: cuando no casa ningún router, responde Traefik y
      # `errserve` ni se entera— y en ese mismo Host el respaldo sigue sirviendo
      # `404` y `503` reales. La coincidencia es exacta, la del endpoint:
      # `/metrics/` y `/METRICS` no lo son y caen en el respaldo como cualquier
      # otro path, sin datos del contador.
      #
      # Y una tercera conjunción cierra el leak de las páginas de tema: el nombre
      # de cada tema **es** la ruta de su página, así que sin ella el respaldo
      # publica las seis páginas crudas —con los marcadores `{{code}}`,
      # `{{reason}}` y `{{description}}` a la vista— a quien pregunte por un Host
      # que no exista. Cubre las tres formas de alcanzar esa página (fin de
      # cadena, barra final o segmento detrás, y barra percent-encoded `%2F`, en
      # las dos cajas) y deja fuera a propósito los dos alias `/default.html` y
      # `/default.htm`, que el servicio intercepta y nunca responde `200`. El
      # porqué y el precio de la regla están en «El leak de las páginas de tema»
      # del README.md.
      traefik.http.routers.errserve-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`) && (!PathRegexp(`\.html?((%2[fF])|/|$)`) || Path(`/default.html`) || Path(`/default.htm`))
      traefik.http.routers.errserve-catchall.entrypoints: web
      traefik.http.routers.errserve-catchall.service: errserve
      traefik.http.routers.errserve-catchall.middlewares: errpages
      traefik.http.routers.errserve-catchall.priority: "1"

      # El middleware aquí es inofensivo: la petición que el propio middleware
      # hace al servicio de errores no vuelve a pasar por los routers, así que
      # no hay recursión (verificado: el cliente recibe 404, no un bucle).
      #
      # Extra didáctico: un router divergente, con el mismo backend pero un
      # middleware que pide SIEMPRE `/404`, para comprobar de un curl quién
      # decide el estado (Traefik conserva el 503 del backend; el cuerpo es el
      # de nuestra página 404).
      traefik.http.middlewares.errfixed.errors.status: "400-599"
      traefik.http.middlewares.errfixed.errors.service: errserve
      traefik.http.middlewares.errfixed.errors.query: "/404"

  # ---------------------------------------------------------------------------
  # Backend de demostración: nginx con `return`, sin ficheros que buscar.
  #   /     -> 404     /boom -> 503     /gone -> 410
  # ---------------------------------------------------------------------------
  backend:
    image: nginx:1.29-alpine
    container_name: errserve-demo-backend
    restart: unless-stopped
    networks:
      - net
    volumes:
      # Es configuración, no datos: bind mount en solo lectura, justificado.
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro,z
    labels:
      traefik.enable: "true"
      traefik.http.services.backend.loadbalancer.server.port: "80"

      # Router normal, con el middleware `errors` delante.
      traefik.http.routers.backend.rule: Host(`app.localtest.me`)
      traefik.http.routers.backend.entrypoints: web
      traefik.http.routers.backend.service: backend
      traefik.http.routers.backend.middlewares: errpages
      traefik.http.routers.backend.priority: "10"

      # El mismo backend con el middleware divergente. Misma regla de longitud
      # que `backend`, por eso lleva prioridad más alta.
      traefik.http.routers.diverge.rule: Host(`diverge.localtest.me`)
      traefik.http.routers.diverge.entrypoints: web
      traefik.http.routers.diverge.service: backend
      traefik.http.routers.diverge.middlewares: errfixed
      traefik.http.routers.diverge.priority: "20"

      # Un router de demostración para el `502`, el código con el que un proxy
      # responde cuando el upstream NO responde. El servicio apunta a un puerto
      # donde nada escucha **en este mismo contenedor**: la conexión se rechaza y
      # Traefik emite 502, que el middleware `errpages` traduce a nuestra página.
      #
      # NO se construye parando el contenedor del backend: un servicio **sin
      # servidores** disponibles da 503, no 502 (medido). Lo que separa los dos
      # códigos es un hecho real, no una casualidad.
      #
      # Prioridad alta para que gane al router de la aplicación en este Host.
      traefik.http.services.caido.loadbalancer.server.port: "9"
      traefik.http.routers.caido.rule: Host(`caido.localtest.me`)
      traefik.http.routers.caido.entrypoints: web
      traefik.http.routers.caido.service: caido
      traefik.http.routers.caido.middlewares: errpages
      traefik.http.routers.caido.priority: "10"

networks:
  # Red propia, no la default bridge: en la del compose los contenedores se
  # resuelven por nombre y Traefik obtiene por la API la IP de cada uno.
  net:
    driver: bridge