demo-01-diagnostico-de-tu-motor.sh
· 8.1 KiB · Bash
Brut
#!/usr/bin/env bash
# =============================================================================
# demo-01 · Diagnóstico de tu motor
# =============================================================================
# A qué punto del guion sirve: puntos 1, 2, 10 y 11
# · 1 «Dos mundos: Podman en mi portátil, Docker en el servidor»
# → `podman --version` y `docker --version`: dos binarios, dos diseños.
# · 2 «Y ahora quiero el servidor sin root»
# → cómo se sabe si tu contenedor corre rootless: `podman info` /
# `docker info`, y el mapa de identificadores del espacio de usuario.
# · 10 «Qué cambia rootless de verdad»
# → la línea `rootlessPortForwarder` de `podman info`: el reenviador
# que hace de proxy y se come la IP de origen.
# · 11 «No es cosa de Podman: es cosa de rootless»
# → el mismo dato, buscado en `docker info`.
#
# Qué se ve en pantalla: cuatro bloques, uno por idea. Cada bloque lanza el
# comando exacto y comenta en una línea lo que significa lo que ha salido.
#
# Estado:
# · Podman (versiones, rootless, mapa de UID, reenviador) → VERIFICADO en
# local con Podman 6.1.4 rootless. La salida real está en
# `verificacion/demo-01-salida.txt`.
# · Docker → NO VERIFICADO aquí: en esta máquina no hay binario `docker`.
# El bloque está escrito para que funcione si lo hay, y si no, imprime la
# salida ESPERADA y qué línea hay que buscar.
#
# Seguridad de la demo: solo LEE. No crea contenedores, no toca la red del
# sistema y no modifica nada. `podman unshare` abre un espacio de nombres de
# usuario, ejecuta `cat` y sale.
# =============================================================================
set -u
titulo() { printf '\n\033[1m── %s\033[0m\n' "$1"; }
orden() { printf '\n\033[1m$ %s\033[0m\n' "$1"; }
nota() { printf ' ↳ %s\n' "$1"; }
esperado() { printf '\n\033[1m[ESPERADO, no ejecutado aquí]\033[0m\n'; }
printf '\033[1mDiagnóstico de tu motor de contenedores\033[0m (puntos 1, 2, 10 y 11)\n'
printf 'Máquina: %s · %s\n' "$(hostname)" "$(date -Is)"
# -----------------------------------------------------------------------------
titulo "1 · ¿Qué motor tengo? · punto 1"
# -----------------------------------------------------------------------------
# La versión dice mucho más de lo que parece: aquí no se elige "Podman o
# Docker" por gusto, son dos arquitecturas. Podman no tiene demonio; Docker sí.
if command -v podman >/dev/null 2>&1; then
orden "podman --version"
podman --version
nota "Un binario más que contesta. No hay servicio con privilegios detrás:"
nota "el contenedor es un hijo de esta sesión y muere con ella."
else
nota "no hay 'podman' en el PATH de esta máquina."
fi
if command -v docker >/dev/null 2>&1; then
orden "docker --version"
docker --version
nota "Mismo software por debajo, distinto modelo: hay un 'dockerd'"
nota "arrancado como root al que le pides el contenedor."
else
esperado
printf '$ docker --version\n'
printf 'Docker version 28.x.y, build xxxxxxx\n'
nota "La versión en sí no te dice si es rootless: para eso, el bloque 2."
fi
# -----------------------------------------------------------------------------
titulo "2 · ¿Mi motor es rootless? · punto 2"
# -----------------------------------------------------------------------------
# Pregunta útil, sobre todo en el servidor: si es rootless, el usuario cero
# dentro del contenedor NO es el root de la máquina.
if command -v podman >/dev/null 2>&1; then
orden "podman info --format '{{.Host.Security.Rootless}}'"
podman info --format '{{.Host.Security.Rootless}}'
nota "'true' = rootless. 'false' = los contenedores corren como root de verdad."
orden "podman info | grep -i rootless"
podman info 2>/dev/null | grep -i rootless
nota "Tres líneas, y ninguna sobra: 'rootlessNetworkCmd' (con qué se monta la"
nota "red: aquí 'pasta'), 'rootlessPortForwarder' (quiénes publican los puertos:"
nota "aquí 'rootlessport') y 'rootless: true', que es la tercera y la misma"
nota "respuesta del comando anterior. El reenviador del bloque 10 es la segunda."
else
nota "no hay 'podman' en el PATH: se salta la comprobación rootless."
fi
# -----------------------------------------------------------------------------
titulo "3 · Qué significa rootless por dentro · punto 2"
# -----------------------------------------------------------------------------
# El truco es del kernel: espacios de nombres de usuario. Dentro, el proceso se
# cree root; fuera, es un usuario sin privilegios con un rango de identificadores
# prestados. Se ve en el mapa de UID.
# Se usa 'podman unshare' para NO tener que crear ningún contenedor: abre el
# espacio de nombres, ejecuta 'cat' y sale.
if command -v podman >/dev/null 2>&1; then
orden "podman unshare cat /proc/self/uid_map"
podman unshare cat /proc/self/uid_map 2>&1
nota "El 0 de la izquierda (dentro) es un 1000 cualquiera (fuera):"
nota "quien escape del contenedor no encuentra el root del host, encuentra un don nadie."
nota "En un contenedor ya en marcha, el mismo dato se ve con:"
nota " cat /proc/self/uid_map (dentro del contenedor)"
else
nota "sin podman no hay espacio de nombres que enseñar."
fi
# -----------------------------------------------------------------------------
titulo "4 · El reenviador: donde se pierde la IP · punto 10"
# -----------------------------------------------------------------------------
# En rootless nadie abre el puerto en el kernel por ti: lo abre un proceso tuyo
# que escucha y reenvía hacia el contenedor. Ese proceso es un proxy, y por eso
# dentro del contenedor la conexión no llega del visitante sino del mensajero.
if command -v podman >/dev/null 2>&1; then
orden "podman info --format '{{.Host.RootlessPortForwarder}}'"
podman info --format '{{.Host.RootlessPortForwarder}}' 2>&1
orden "podman info --format '{{.Host.RootlessNetworkCmd}}'"
podman info --format '{{.Host.RootlessNetworkCmd}}' 2>&1
nota "Esos dos nombres son la pieza de la que habla el guion: el reenvío"
nota "de puertos ocurre en el espacio de usuario, no en el NAT del kernel."
else
nota "sin podman no hay reenviador que enseñar."
fi
# -----------------------------------------------------------------------------
titulo "5 · ¿Y en Docker? Exactamente lo mismo · punto 11"
# -----------------------------------------------------------------------------
# La frase del episodio: no es cosa de Podman, es cosa de rootless. Docker
# rootless arrastra el mismo comportamiento porque se apoya en RootlessKit.
if command -v docker >/dev/null 2>&1; then
orden "docker info --format '{{.SecurityOptions}}'"
docker info --format '{{.SecurityOptions}}' 2>&1
nota "Si aparece 'rootless' en la lista, es rootless. Si no aparece, no lo es."
orden "docker context inspect --format '{{.Endpoints.docker.Host}}'"
docker context inspect --format '{{.Endpoints.docker.Host}}' 2>&1
nota "Segunda pista: un socket en /run/user/<uid>/docker.sock es rootless;"
nota "unix:///var/run/docker.sock es el demonio de root de toda la vida."
else
esperado
printf '$ docker info --format '"'"'{{.SecurityOptions}}'"'"'\n'
printf '[name=seccomp,profile=builtin name=rootless name=cgroupns]\n\n'
printf '$ docker context inspect --format '"'"'{{.Endpoints.docker.Host}}'"'"'\n'
printf 'unix:///run/user/1000/docker.sock\n'
nota "Docker rootless se instala aparte y se arranca como usuario;"
nota "si no lo has hecho, tu Docker es rootful aunque no te lo diga la versión."
fi
# -----------------------------------------------------------------------------
titulo "Cierre del bloque"
# -----------------------------------------------------------------------------
printf ' · Dos binarios, dos diseños: sin demonio (Podman) / con demonio (Docker).\n'
printf ' · Rootless se comprueba con %s, no con la versión.\n' "'info'"
printf ' · Y en rootless los puertos los publica un reenviador: ese es el que\n'
printf ' hace que dentro del contenedor veas al mensajero y no al visitante.\n\n'
exit 0
| 1 | #!/usr/bin/env bash |
| 2 | # ============================================================================= |
| 3 | # demo-01 · Diagnóstico de tu motor |
| 4 | # ============================================================================= |
| 5 | # A qué punto del guion sirve: puntos 1, 2, 10 y 11 |
| 6 | # · 1 «Dos mundos: Podman en mi portátil, Docker en el servidor» |
| 7 | # → `podman --version` y `docker --version`: dos binarios, dos diseños. |
| 8 | # · 2 «Y ahora quiero el servidor sin root» |
| 9 | # → cómo se sabe si tu contenedor corre rootless: `podman info` / |
| 10 | # `docker info`, y el mapa de identificadores del espacio de usuario. |
| 11 | # · 10 «Qué cambia rootless de verdad» |
| 12 | # → la línea `rootlessPortForwarder` de `podman info`: el reenviador |
| 13 | # que hace de proxy y se come la IP de origen. |
| 14 | # · 11 «No es cosa de Podman: es cosa de rootless» |
| 15 | # → el mismo dato, buscado en `docker info`. |
| 16 | # |
| 17 | # Qué se ve en pantalla: cuatro bloques, uno por idea. Cada bloque lanza el |
| 18 | # comando exacto y comenta en una línea lo que significa lo que ha salido. |
| 19 | # |
| 20 | # Estado: |
| 21 | # · Podman (versiones, rootless, mapa de UID, reenviador) → VERIFICADO en |
| 22 | # local con Podman 6.1.4 rootless. La salida real está en |
| 23 | # `verificacion/demo-01-salida.txt`. |
| 24 | # · Docker → NO VERIFICADO aquí: en esta máquina no hay binario `docker`. |
| 25 | # El bloque está escrito para que funcione si lo hay, y si no, imprime la |
| 26 | # salida ESPERADA y qué línea hay que buscar. |
| 27 | # |
| 28 | # Seguridad de la demo: solo LEE. No crea contenedores, no toca la red del |
| 29 | # sistema y no modifica nada. `podman unshare` abre un espacio de nombres de |
| 30 | # usuario, ejecuta `cat` y sale. |
| 31 | # ============================================================================= |
| 32 | |
| 33 | set -u |
| 34 | |
| 35 | titulo() { printf '\n\033[1m── %s\033[0m\n' "$1"; } |
| 36 | orden() { printf '\n\033[1m$ %s\033[0m\n' "$1"; } |
| 37 | nota() { printf ' ↳ %s\n' "$1"; } |
| 38 | esperado() { printf '\n\033[1m[ESPERADO, no ejecutado aquí]\033[0m\n'; } |
| 39 | |
| 40 | printf '\033[1mDiagnóstico de tu motor de contenedores\033[0m (puntos 1, 2, 10 y 11)\n' |
| 41 | printf 'Máquina: %s · %s\n' "$(hostname)" "$(date -Is)" |
| 42 | |
| 43 | # ----------------------------------------------------------------------------- |
| 44 | titulo "1 · ¿Qué motor tengo? · punto 1" |
| 45 | # ----------------------------------------------------------------------------- |
| 46 | # La versión dice mucho más de lo que parece: aquí no se elige "Podman o |
| 47 | # Docker" por gusto, son dos arquitecturas. Podman no tiene demonio; Docker sí. |
| 48 | if command -v podman >/dev/null 2>&1; then |
| 49 | orden "podman --version" |
| 50 | podman --version |
| 51 | nota "Un binario más que contesta. No hay servicio con privilegios detrás:" |
| 52 | nota "el contenedor es un hijo de esta sesión y muere con ella." |
| 53 | else |
| 54 | nota "no hay 'podman' en el PATH de esta máquina." |
| 55 | fi |
| 56 | |
| 57 | if command -v docker >/dev/null 2>&1; then |
| 58 | orden "docker --version" |
| 59 | docker --version |
| 60 | nota "Mismo software por debajo, distinto modelo: hay un 'dockerd'" |
| 61 | nota "arrancado como root al que le pides el contenedor." |
| 62 | else |
| 63 | esperado |
| 64 | printf '$ docker --version\n' |
| 65 | printf 'Docker version 28.x.y, build xxxxxxx\n' |
| 66 | nota "La versión en sí no te dice si es rootless: para eso, el bloque 2." |
| 67 | fi |
| 68 | |
| 69 | # ----------------------------------------------------------------------------- |
| 70 | titulo "2 · ¿Mi motor es rootless? · punto 2" |
| 71 | # ----------------------------------------------------------------------------- |
| 72 | # Pregunta útil, sobre todo en el servidor: si es rootless, el usuario cero |
| 73 | # dentro del contenedor NO es el root de la máquina. |
| 74 | if command -v podman >/dev/null 2>&1; then |
| 75 | orden "podman info --format '{{.Host.Security.Rootless}}'" |
| 76 | podman info --format '{{.Host.Security.Rootless}}' |
| 77 | nota "'true' = rootless. 'false' = los contenedores corren como root de verdad." |
| 78 | |
| 79 | orden "podman info | grep -i rootless" |
| 80 | podman info 2>/dev/null | grep -i rootless |
| 81 | nota "Tres líneas, y ninguna sobra: 'rootlessNetworkCmd' (con qué se monta la" |
| 82 | nota "red: aquí 'pasta'), 'rootlessPortForwarder' (quiénes publican los puertos:" |
| 83 | nota "aquí 'rootlessport') y 'rootless: true', que es la tercera y la misma" |
| 84 | nota "respuesta del comando anterior. El reenviador del bloque 10 es la segunda." |
| 85 | else |
| 86 | nota "no hay 'podman' en el PATH: se salta la comprobación rootless." |
| 87 | fi |
| 88 | |
| 89 | # ----------------------------------------------------------------------------- |
| 90 | titulo "3 · Qué significa rootless por dentro · punto 2" |
| 91 | # ----------------------------------------------------------------------------- |
| 92 | # El truco es del kernel: espacios de nombres de usuario. Dentro, el proceso se |
| 93 | # cree root; fuera, es un usuario sin privilegios con un rango de identificadores |
| 94 | # prestados. Se ve en el mapa de UID. |
| 95 | # Se usa 'podman unshare' para NO tener que crear ningún contenedor: abre el |
| 96 | # espacio de nombres, ejecuta 'cat' y sale. |
| 97 | if command -v podman >/dev/null 2>&1; then |
| 98 | orden "podman unshare cat /proc/self/uid_map" |
| 99 | podman unshare cat /proc/self/uid_map 2>&1 |
| 100 | nota "El 0 de la izquierda (dentro) es un 1000 cualquiera (fuera):" |
| 101 | nota "quien escape del contenedor no encuentra el root del host, encuentra un don nadie." |
| 102 | nota "En un contenedor ya en marcha, el mismo dato se ve con:" |
| 103 | nota " cat /proc/self/uid_map (dentro del contenedor)" |
| 104 | else |
| 105 | nota "sin podman no hay espacio de nombres que enseñar." |
| 106 | fi |
| 107 | |
| 108 | # ----------------------------------------------------------------------------- |
| 109 | titulo "4 · El reenviador: donde se pierde la IP · punto 10" |
| 110 | # ----------------------------------------------------------------------------- |
| 111 | # En rootless nadie abre el puerto en el kernel por ti: lo abre un proceso tuyo |
| 112 | # que escucha y reenvía hacia el contenedor. Ese proceso es un proxy, y por eso |
| 113 | # dentro del contenedor la conexión no llega del visitante sino del mensajero. |
| 114 | if command -v podman >/dev/null 2>&1; then |
| 115 | orden "podman info --format '{{.Host.RootlessPortForwarder}}'" |
| 116 | podman info --format '{{.Host.RootlessPortForwarder}}' 2>&1 |
| 117 | orden "podman info --format '{{.Host.RootlessNetworkCmd}}'" |
| 118 | podman info --format '{{.Host.RootlessNetworkCmd}}' 2>&1 |
| 119 | nota "Esos dos nombres son la pieza de la que habla el guion: el reenvío" |
| 120 | nota "de puertos ocurre en el espacio de usuario, no en el NAT del kernel." |
| 121 | else |
| 122 | nota "sin podman no hay reenviador que enseñar." |
| 123 | fi |
| 124 | |
| 125 | # ----------------------------------------------------------------------------- |
| 126 | titulo "5 · ¿Y en Docker? Exactamente lo mismo · punto 11" |
| 127 | # ----------------------------------------------------------------------------- |
| 128 | # La frase del episodio: no es cosa de Podman, es cosa de rootless. Docker |
| 129 | # rootless arrastra el mismo comportamiento porque se apoya en RootlessKit. |
| 130 | if command -v docker >/dev/null 2>&1; then |
| 131 | orden "docker info --format '{{.SecurityOptions}}'" |
| 132 | docker info --format '{{.SecurityOptions}}' 2>&1 |
| 133 | nota "Si aparece 'rootless' en la lista, es rootless. Si no aparece, no lo es." |
| 134 | |
| 135 | orden "docker context inspect --format '{{.Endpoints.docker.Host}}'" |
| 136 | docker context inspect --format '{{.Endpoints.docker.Host}}' 2>&1 |
| 137 | nota "Segunda pista: un socket en /run/user/<uid>/docker.sock es rootless;" |
| 138 | nota "unix:///var/run/docker.sock es el demonio de root de toda la vida." |
| 139 | else |
| 140 | esperado |
| 141 | printf '$ docker info --format '"'"'{{.SecurityOptions}}'"'"'\n' |
| 142 | printf '[name=seccomp,profile=builtin name=rootless name=cgroupns]\n\n' |
| 143 | printf '$ docker context inspect --format '"'"'{{.Endpoints.docker.Host}}'"'"'\n' |
| 144 | printf 'unix:///run/user/1000/docker.sock\n' |
| 145 | nota "Docker rootless se instala aparte y se arranca como usuario;" |
| 146 | nota "si no lo has hecho, tu Docker es rootful aunque no te lo diga la versión." |
| 147 | fi |
| 148 | |
| 149 | # ----------------------------------------------------------------------------- |
| 150 | titulo "Cierre del bloque" |
| 151 | # ----------------------------------------------------------------------------- |
| 152 | printf ' · Dos binarios, dos diseños: sin demonio (Podman) / con demonio (Docker).\n' |
| 153 | printf ' · Rootless se comprueba con %s, no con la versión.\n' "'info'" |
| 154 | printf ' · Y en rootless los puertos los publica un reenviador: ese es el que\n' |
| 155 | printf ' hace que dentro del contenedor veas al mensajero y no al visitante.\n\n' |
| 156 | exit 0 |
| 157 |
demo-02-correcciones-traefik-y-umami.sh
· 7.2 KiB · Bash
Brut
#!/usr/bin/env bash
# =============================================================================
# demo-02 · Las correcciones de configuración
# =============================================================================
# A qué punto del guion sirve: punto 13
# «Y se puede tratar en Traefik y en Umami»
#
# Qué se ve en pantalla: los dos fragmentos correctos, con la línea que importa
# señalada, y una comprobación de que lo son de verdad.
# 1. Traefik → `config/traefik-entrypoints.yml`
# forwardedHeaders.trustedIPs y proxyProtocol.trustedIPs
# apuntando SOLO al proxy correcto.
# 2. Umami → `config/umami.env`
# CLIENT_IP_HEADER=x-real-ip
#
# La frase que resume las dos: una cabecera que el proxy REESCRIBE frente a una
# cabecera que CUALQUIERA PUEDE ESCRIBIR.
#
# Estado: VERIFICADO en local. El YAML se parsea y el fichero de entorno se
# comprueba con comandos; la salida real está en
# `verificacion/demo-02-salida.txt`.
#
# Seguridad: solo lee los ficheros de `config/`. No hay ningún secreto en ellos
# (el token de Cloudflare, si algún día se usa, no vive aquí: va en un `podman
# secret`). La única dirección de la confianza es la del borde de este montaje
# (loopback); donde se usa un valor de mentira se marca como rango de
# documentación (RFC 5737).
# =============================================================================
set -u
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRAEFIK="$AQUI/config/traefik-entrypoints.yml"
UMAMI="$AQUI/config/umami.env"
titulo() { printf '\n\033[1m── %s\033[0m\n' "$1"; }
orden() { printf '\n\033[1m$ %s\033[0m\n' "$1"; }
nota() { printf ' ↳ %s\n' "$1"; }
ok() { printf ' \033[1m✔ %s\033[0m\n' "$1"; }
fallo() { printf ' \033[1m✘ %s\033[0m\n' "$1"; }
rc_final=0
printf '\033[1mLas dos correcciones del punto 13\033[0m\n'
# -----------------------------------------------------------------------------
titulo "1 · Traefik: de quién se fía el proxy"
# -----------------------------------------------------------------------------
# El error de partida (lo que se cuenta en voz alta, no hace falta fichero):
# proxyProtocol: trustedIPs: [el rango de tu vecindario de contenedores]
# ← confía en TODO el vecindario: cualquiera
# que alcance el puerto puede contar que
# viene de donde le apetezca
# forwardedHeaders: insecure: true ← se cree cualquier cabecera
# Con eso, cualquiera que alcance el puerto de Traefik entra diciendo que viene de
# donde quiera.
orden "cat config/traefik-entrypoints.yml"
cat "$TRAEFIK"
# ¿Se parsea? Un YAML roto en el arranque deja el proxy sin servicios.
if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null; then
orden "python3 -c 'yaml.safe_load(...)' # ¿el YAML es válido y dice lo que creemos?"
python3 - "$TRAEFIK" <<'PY'
import sys, yaml
with open(sys.argv[1]) as f:
cfg = yaml.safe_load(f)
ep = cfg["entryPoints"]["websecure"]
print("address :", ep["address"])
print("proxyProtocol.trustedIPs :", ep["proxyProtocol"]["trustedIPs"])
print("forwardedHeaders.insecure :", ep.get("forwardedHeaders", {}).get("insecure", False))
print("forwardedHeaders.trustedIPs :", ep["forwardedHeaders"]["trustedIPs"])
PY
ok "YAML válido y con las listas presentes."
else
nota "python3 + PyYAML no disponibles: se salta la comprobación de sintaxis."
fi
# Auditoría: lo que NO debe haber. Solo miran las líneas ACTIVAS (sin '#').
orden "grep -vE '^[[:space:]]*#' config/traefik-entrypoints.yml | grep -nE 'insecure: true|/[0-9]{1,2}\"' "
if grep -vE '^[[:space:]]*#' "$TRAEFIK" | grep -E 'insecure: true|/(0|[0-9]|1[0-9]|2[0-3])"'; then
fallo "hay un rango demasiado ancho o un 'insecure' activo."
rc_final=1
else
ok "ni 'insecure: true' ni rangos anchos activos: solo la IP del borde."
fi
# La misma auditoría, pero entendiendo el YAML (si hay python3). Es la que manda.
if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null; then
orden "python3 -c 'auditar el YAML: insecure y prefijos de red'"
if python3 - "$TRAEFIK" <<'PY'
import sys, ipaddress, yaml
ep = yaml.safe_load(open(sys.argv[1]))["entryPoints"]["websecure"]
problemas = []
for nombre in ("forwardedHeaders", "proxyProtocol"):
bloque = ep.get(nombre) or {}
if bloque.get("insecure"):
problemas.append(f"{nombre}.insecure: true ← se cree a cualquiera")
for cidr in bloque.get("trustedIPs") or []:
red = ipaddress.ip_network(cidr, strict=False)
minimo = 24 if red.version == 4 else 64
if red.prefixlen < minimo:
problemas.append(f"{nombre}.trustedIPs demasiado ancho: {cidr}")
else:
print(f" {nombre}.trustedIPs → {cidr} (red pequeña, correcto)")
if problemas:
for p in problemas:
print(" ✘", p)
sys.exit(1)
print(" ✔ sin 'insecure' y sin rangos anchos.")
PY
then
ok "el YAML auditado no tiene confianzas excesivas."
else
fallo "el YAML confía en más gente de la que debería."
rc_final=1
fi
fi
# -----------------------------------------------------------------------------
titulo "2 · Umami: qué cabecera lee"
# -----------------------------------------------------------------------------
# Mismo razonamiento, al revés: aquí no se restringe QUIÉN puede llegar, se elige
# QUÉ cabecera creerse. Se elige la que el proxy reescribe.
orden "cat config/umami.env"
cat "$UMAMI"
orden "grep -n '^CLIENT_IP_HEADER' config/umami.env"
if grep -q '^CLIENT_IP_HEADER=x-real-ip' "$UMAMI"; then
ok "CLIENT_IP_HEADER=x-real-ip → la cabecera que Traefik reconstruye."
else
fallo "CLIENT_IP_HEADER no apunta a x-real-ip."
rc_final=1
fi
orden "grep -nE 'cf-connecting-ip|true-client-ip' config/umami.env # no debe haber ninguna activa"
if grep -E '^[[:space:]]*CLIENT_IP_HEADER=.*(cf-connecting-ip|true-client-ip)' "$UMAMI"; then
fallo "queda una cabecera que cualquiera puede escribir."
rc_final=1
else
ok "ninguna cabecera de cliente (cf-connecting-ip / true-client-ip) activa."
fi
# -----------------------------------------------------------------------------
titulo "Por qué, en una frase (para decir en el aire)"
# -----------------------------------------------------------------------------
cat <<'TEXTO'
· En el proxy: no te fíes de todos. 'trustedIPs' con la IP exacta del borde;
si te fías del rango entero, cualquiera que llegue te cuenta de dónde viene.
· En la analítica: lee la cabecera que el proxy reescribe (x-real-ip), no una
que pueda rellenar el primero que pase (cf-connecting-ip, x-forwarded-for).
· Y recuerda el límite: esto ordena la casa, no tapa el agujero. Si el 8443
está abierto al mundo, la fiabilidad la da el firewall, no la cabecera.
· Y ojo con el alcance: esto funcionaba… hasta que un sábado por la tarde el
bloqueo judicial tumbó el rango entero del proxy que había delante.
TEXTO
printf '\n'
if [ "$rc_final" -eq 0 ]; then
printf '\033[1mResultado: las dos correcciones están en su sitio.\033[0m\n\n'
else
printf '\033[1mResultado: hay algo que corregir (ver arriba).\033[0m\n\n'
fi
exit "$rc_final"
| 1 | #!/usr/bin/env bash |
| 2 | # ============================================================================= |
| 3 | # demo-02 · Las correcciones de configuración |
| 4 | # ============================================================================= |
| 5 | # A qué punto del guion sirve: punto 13 |
| 6 | # «Y se puede tratar en Traefik y en Umami» |
| 7 | # |
| 8 | # Qué se ve en pantalla: los dos fragmentos correctos, con la línea que importa |
| 9 | # señalada, y una comprobación de que lo son de verdad. |
| 10 | # 1. Traefik → `config/traefik-entrypoints.yml` |
| 11 | # forwardedHeaders.trustedIPs y proxyProtocol.trustedIPs |
| 12 | # apuntando SOLO al proxy correcto. |
| 13 | # 2. Umami → `config/umami.env` |
| 14 | # CLIENT_IP_HEADER=x-real-ip |
| 15 | # |
| 16 | # La frase que resume las dos: una cabecera que el proxy REESCRIBE frente a una |
| 17 | # cabecera que CUALQUIERA PUEDE ESCRIBIR. |
| 18 | # |
| 19 | # Estado: VERIFICADO en local. El YAML se parsea y el fichero de entorno se |
| 20 | # comprueba con comandos; la salida real está en |
| 21 | # `verificacion/demo-02-salida.txt`. |
| 22 | # |
| 23 | # Seguridad: solo lee los ficheros de `config/`. No hay ningún secreto en ellos |
| 24 | # (el token de Cloudflare, si algún día se usa, no vive aquí: va en un `podman |
| 25 | # secret`). La única dirección de la confianza es la del borde de este montaje |
| 26 | # (loopback); donde se usa un valor de mentira se marca como rango de |
| 27 | # documentación (RFC 5737). |
| 28 | # ============================================================================= |
| 29 | |
| 30 | set -u |
| 31 | |
| 32 | AQUI="$(cd "$(dirname "$0")" && pwd)" |
| 33 | TRAEFIK="$AQUI/config/traefik-entrypoints.yml" |
| 34 | UMAMI="$AQUI/config/umami.env" |
| 35 | |
| 36 | titulo() { printf '\n\033[1m── %s\033[0m\n' "$1"; } |
| 37 | orden() { printf '\n\033[1m$ %s\033[0m\n' "$1"; } |
| 38 | nota() { printf ' ↳ %s\n' "$1"; } |
| 39 | ok() { printf ' \033[1m✔ %s\033[0m\n' "$1"; } |
| 40 | fallo() { printf ' \033[1m✘ %s\033[0m\n' "$1"; } |
| 41 | |
| 42 | rc_final=0 |
| 43 | |
| 44 | printf '\033[1mLas dos correcciones del punto 13\033[0m\n' |
| 45 | |
| 46 | # ----------------------------------------------------------------------------- |
| 47 | titulo "1 · Traefik: de quién se fía el proxy" |
| 48 | # ----------------------------------------------------------------------------- |
| 49 | # El error de partida (lo que se cuenta en voz alta, no hace falta fichero): |
| 50 | # proxyProtocol: trustedIPs: [el rango de tu vecindario de contenedores] |
| 51 | # ← confía en TODO el vecindario: cualquiera |
| 52 | # que alcance el puerto puede contar que |
| 53 | # viene de donde le apetezca |
| 54 | # forwardedHeaders: insecure: true ← se cree cualquier cabecera |
| 55 | # Con eso, cualquiera que alcance el puerto de Traefik entra diciendo que viene de |
| 56 | # donde quiera. |
| 57 | orden "cat config/traefik-entrypoints.yml" |
| 58 | cat "$TRAEFIK" |
| 59 | |
| 60 | # ¿Se parsea? Un YAML roto en el arranque deja el proxy sin servicios. |
| 61 | if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null; then |
| 62 | orden "python3 -c 'yaml.safe_load(...)' # ¿el YAML es válido y dice lo que creemos?" |
| 63 | python3 - "$TRAEFIK" <<'PY' |
| 64 | import sys, yaml |
| 65 | with open(sys.argv[1]) as f: |
| 66 | cfg = yaml.safe_load(f) |
| 67 | ep = cfg["entryPoints"]["websecure"] |
| 68 | print("address :", ep["address"]) |
| 69 | print("proxyProtocol.trustedIPs :", ep["proxyProtocol"]["trustedIPs"]) |
| 70 | print("forwardedHeaders.insecure :", ep.get("forwardedHeaders", {}).get("insecure", False)) |
| 71 | print("forwardedHeaders.trustedIPs :", ep["forwardedHeaders"]["trustedIPs"]) |
| 72 | PY |
| 73 | ok "YAML válido y con las listas presentes." |
| 74 | else |
| 75 | nota "python3 + PyYAML no disponibles: se salta la comprobación de sintaxis." |
| 76 | fi |
| 77 | |
| 78 | # Auditoría: lo que NO debe haber. Solo miran las líneas ACTIVAS (sin '#'). |
| 79 | orden "grep -vE '^[[:space:]]*#' config/traefik-entrypoints.yml | grep -nE 'insecure: true|/[0-9]{1,2}\"' " |
| 80 | if grep -vE '^[[:space:]]*#' "$TRAEFIK" | grep -E 'insecure: true|/(0|[0-9]|1[0-9]|2[0-3])"'; then |
| 81 | fallo "hay un rango demasiado ancho o un 'insecure' activo." |
| 82 | rc_final=1 |
| 83 | else |
| 84 | ok "ni 'insecure: true' ni rangos anchos activos: solo la IP del borde." |
| 85 | fi |
| 86 | |
| 87 | # La misma auditoría, pero entendiendo el YAML (si hay python3). Es la que manda. |
| 88 | if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null; then |
| 89 | orden "python3 -c 'auditar el YAML: insecure y prefijos de red'" |
| 90 | if python3 - "$TRAEFIK" <<'PY' |
| 91 | import sys, ipaddress, yaml |
| 92 | ep = yaml.safe_load(open(sys.argv[1]))["entryPoints"]["websecure"] |
| 93 | problemas = [] |
| 94 | for nombre in ("forwardedHeaders", "proxyProtocol"): |
| 95 | bloque = ep.get(nombre) or {} |
| 96 | if bloque.get("insecure"): |
| 97 | problemas.append(f"{nombre}.insecure: true ← se cree a cualquiera") |
| 98 | for cidr in bloque.get("trustedIPs") or []: |
| 99 | red = ipaddress.ip_network(cidr, strict=False) |
| 100 | minimo = 24 if red.version == 4 else 64 |
| 101 | if red.prefixlen < minimo: |
| 102 | problemas.append(f"{nombre}.trustedIPs demasiado ancho: {cidr}") |
| 103 | else: |
| 104 | print(f" {nombre}.trustedIPs → {cidr} (red pequeña, correcto)") |
| 105 | if problemas: |
| 106 | for p in problemas: |
| 107 | print(" ✘", p) |
| 108 | sys.exit(1) |
| 109 | print(" ✔ sin 'insecure' y sin rangos anchos.") |
| 110 | PY |
| 111 | then |
| 112 | ok "el YAML auditado no tiene confianzas excesivas." |
| 113 | else |
| 114 | fallo "el YAML confía en más gente de la que debería." |
| 115 | rc_final=1 |
| 116 | fi |
| 117 | fi |
| 118 | |
| 119 | # ----------------------------------------------------------------------------- |
| 120 | titulo "2 · Umami: qué cabecera lee" |
| 121 | # ----------------------------------------------------------------------------- |
| 122 | # Mismo razonamiento, al revés: aquí no se restringe QUIÉN puede llegar, se elige |
| 123 | # QUÉ cabecera creerse. Se elige la que el proxy reescribe. |
| 124 | orden "cat config/umami.env" |
| 125 | cat "$UMAMI" |
| 126 | |
| 127 | orden "grep -n '^CLIENT_IP_HEADER' config/umami.env" |
| 128 | if grep -q '^CLIENT_IP_HEADER=x-real-ip' "$UMAMI"; then |
| 129 | ok "CLIENT_IP_HEADER=x-real-ip → la cabecera que Traefik reconstruye." |
| 130 | else |
| 131 | fallo "CLIENT_IP_HEADER no apunta a x-real-ip." |
| 132 | rc_final=1 |
| 133 | fi |
| 134 | |
| 135 | orden "grep -nE 'cf-connecting-ip|true-client-ip' config/umami.env # no debe haber ninguna activa" |
| 136 | if grep -E '^[[:space:]]*CLIENT_IP_HEADER=.*(cf-connecting-ip|true-client-ip)' "$UMAMI"; then |
| 137 | fallo "queda una cabecera que cualquiera puede escribir." |
| 138 | rc_final=1 |
| 139 | else |
| 140 | ok "ninguna cabecera de cliente (cf-connecting-ip / true-client-ip) activa." |
| 141 | fi |
| 142 | |
| 143 | # ----------------------------------------------------------------------------- |
| 144 | titulo "Por qué, en una frase (para decir en el aire)" |
| 145 | # ----------------------------------------------------------------------------- |
| 146 | cat <<'TEXTO' |
| 147 | · En el proxy: no te fíes de todos. 'trustedIPs' con la IP exacta del borde; |
| 148 | si te fías del rango entero, cualquiera que llegue te cuenta de dónde viene. |
| 149 | · En la analítica: lee la cabecera que el proxy reescribe (x-real-ip), no una |
| 150 | que pueda rellenar el primero que pase (cf-connecting-ip, x-forwarded-for). |
| 151 | · Y recuerda el límite: esto ordena la casa, no tapa el agujero. Si el 8443 |
| 152 | está abierto al mundo, la fiabilidad la da el firewall, no la cabecera. |
| 153 | · Y ojo con el alcance: esto funcionaba… hasta que un sábado por la tarde el |
| 154 | bloqueo judicial tumbó el rango entero del proxy que había delante. |
| 155 | TEXTO |
| 156 | |
| 157 | printf '\n' |
| 158 | if [ "$rc_final" -eq 0 ]; then |
| 159 | printf '\033[1mResultado: las dos correcciones están en su sitio.\033[0m\n\n' |
| 160 | else |
| 161 | printf '\033[1mResultado: hay algo que corregir (ver arriba).\033[0m\n\n' |
| 162 | fi |
| 163 | exit "$rc_final" |
| 164 |
demo-03-prueba-del-ciudadano.sh
· 9.2 KiB · Bash
Brut
#!/usr/bin/env bash
# =============================================================================
# demo-03 · La prueba del ciudadano
# =============================================================================
# A qué punto del guion sirve: punto 15
# «Qué puedes hacer tú: prueba, mide y denuncia»
#
# Qué hace: cuando un sitio legítimo te falla, guarda la prueba CON FECHA en un
# fichero fechado. Dominio, operador, hora exacta y el error tal cual.
# · curl → ¿responde? ¿con qué código? ¿cuánto tarda? y el error completo.
# · dig → ¿resuelve? ¿a qué direcciones? ¿con qué servidor de nombres?
# · whois → ¿de quién es esa dirección? (si es de un rango compartido, no se
# ha caído «tu» servidor: se ha caído un vector que usan cientos de
# miles de sitios). Si no hay `whois` instalado, se usa RDAP con
# `curl`, que da la misma información sin instalar nada.
#
# Uso:
# ./demo-03-prueba-del-ciudadano.sh <dominio> [operador]
# ./demo-03-prueba-del-ciudadano.sh tudominio.example "Mi operador"
#
# El informe se guarda en:
# $PRUEBAS_DIR/<AAAA-MM-DD_HHMMSS>-prueba-<dominio>.txt
# (por defecto $PRUEBAS_DIR=/tmp/opencode/pruebas-ciudadano)
#
# Códigos de salida (para que sirva también en un cron o en un script):
# 0 = el sitio responde
# 1 = el sitio NO responde (conexión, TLS o tiempo agotado)
# 2 = el nombre no resuelve
# 3 = no se ha podido escribir el informe
#
# Estado: VERIFICADO en local (cuatro casos: responde, no resuelve, no responde
# y bloqueo detectado). Salidas reales en `verificacion/demo-03-*.txt`.
#
# Seguridad de la demo: solo lectura. Nada de `ssh`, nada de tocar servidores
# ajenos, nada que modifique el sistema. El único tráfico que sale es el de tus
# propias consultas DNS/HTTP, que es justo lo que se está probando.
# =============================================================================
set -u
DOMINIO="${1:-example.com}"
OPERADOR="${2:-${OPERADOR:-(anota aquí tu operador: Movistar, Vodafone, Digi…)}}"
DESTINO="${PRUEBAS_DIR:-/tmp/opencode/pruebas-ciudadano}"
if [ "$DOMINIO" = "-h" ] || [ "$DOMINIO" = "--help" ]; then
sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'
exit 0
fi
mkdir -p "$DESTINO" || { printf 'no se puede crear %s\n' "$DESTINO" >&2; exit 3; }
FECHA="$(date +%Y-%m-%d_%H%M%S)"
INFORME="$DESTINO/${FECHA}-prueba-${DOMINIO}.txt"
: > "$INFORME" || exit 3
ULTIMO_RC=0
salida() { printf '%s\n' "$*" | tee -a "$INFORME"; }
orden() { printf '\n$ %s\n' "$*" | tee -a "$INFORME"; }
run() { # run <comando…> → lanza, guarda la salida y el código, nunca aborta
local cabecera salida_cmd rc=0
cabecera="$(printf '$'; for a in "$@"; do
case "$a" in
*" "*) printf " '%s'" "$a" ;;
*) printf ' %s' "$a" ;;
esac
done)"
printf '\n%s\n' "$cabecera" | tee -a "$INFORME"
salida_cmd="$("$@" 2>&1)" || rc=$?
printf '%s\n' "$salida_cmd" | tee -a "$INFORME"
if [ "$rc" -ne 0 ]; then printf '[exit %s]\n' "$rc" | tee -a "$INFORME"; fi
ULTIMO_RC=$rc
return 0
}
# ------------------------------------------------------------------ cabecera
salida "================================================================"
salida " PRUEBA DE ACCESO — prueba del ciudadano"
salida "================================================================"
salida " Dominio : $DOMINIO"
salida " Operador : $OPERADOR"
salida " Fecha : $(date -Is)"
salida " Equipo : $(hostname) · $(uname -sr)"
salida "================================================================"
salida ""
salida "Nota: esta prueba NO dice quién bloquea ni por qué. Solo deja constancia"
salida "fechada de lo que ha pasado desde tu conexión. Una prueba, o varias, es"
salida "lo que se adjunta a una reclamación."
# --------------------------------------------------------------- 1 · curl
salida ""
salida "--- 1 · ¿El sitio responde? -------------------------------------"
if command -v curl >/dev/null 2>&1; then
run curl -sS -o /dev/null \
--connect-timeout 10 --max-time 20 \
-w 'HTTP %{http_code} · connect %{time_connect}s · TLS %{time_appconnect}s · total %{time_total}s · IP destino %{remote_ip}\n' \
"https://$DOMINIO/"
RC_CURL=$ULTIMO_RC
orden "curl -v https://$DOMINIO/ # el error tal cual, para adjuntar"
curl -v -o /dev/null --connect-timeout 10 --max-time 20 "https://$DOMINIO/" 2>&1 \
| tee -a "$INFORME"
else
salida "curl no está instalado: sin esta parte no hay prueba de acceso."
RC_CURL=127
fi
# --------------------------------------------------------------- 2 · dig
salida ""
salida "--- 2 · ¿Resuelve el nombre? ------------------------------------"
IP_A=""
if command -v dig >/dev/null 2>&1; then
run dig "$DOMINIO" A
IP_A="$(dig +short A "$DOMINIO" | grep -E '^[0-9.]+$' | head -1)"
run dig +short "$DOMINIO" AAAA
run dig +short "$DOMINIO" NS
if [ -n "$IP_A" ]; then
salida ""
salida "Primera dirección IPv4 anotada: $IP_A"
fi
else
salida "dig no está instalado (paquete bind-utils / dnsutils)."
fi
# --------------------------------------------------------------- 3 · whois
salida ""
salida "--- 3 · ¿De quién es esa dirección? -----------------------------"
salida "(si es de un rango compartido, no está caído «tu» servidor:"
salida " está caído un vector que usan cientos de miles de sitios)"
if [ -z "$IP_A" ]; then
salida "Sin dirección IPv4 no hay nada que consultar."
elif command -v whois >/dev/null 2>&1; then
run whois "$IP_A"
else
salida "No hay 'whois' instalado en este equipo. Alternativa sin instalar nada:"
salida "RDAP, el registro de direcciones que habla HTTP."
if command -v curl >/dev/null 2>&1; then
orden "curl -sSL https://rdap.org/ip/$IP_A"
RDAP="$(curl -sSL --connect-timeout 10 --max-time 20 "https://rdap.org/ip/$IP_A" 2>&1)" \
|| salida "(la consulta RDAP ha fallado)"
if command -v jq >/dev/null 2>&1 && printf '%s' "$RDAP" | jq -e . >/dev/null 2>&1; then
printf '%s' "$RDAP" | jq -r '
" titular : " + (.name // .handle // (.entities[0].handle // "?")),
" registro : " + (.handle // "?"),
" país : " + (.country // "?"),
" rango : " + (
if .["cidr0_cidrs"] then
(.["cidr0_cidrs"][0] | "\(.v4prefix // .v6prefix)/\(.length)")
elif .startAddress then "\(.startAddress) - \(.endAddress)"
else "?" end
)
' | tee -a "$INFORME"
else
printf '%s\n' "$RDAP" | head -40 | tee -a "$INFORME"
fi
fi
fi
# ------------------------------------------------------------- veredicto
salida ""
salida "--- Resultado ---------------------------------------------------"
if [ -n "$IP_A" ] && [ "${RC_CURL:-1}" -eq 0 ]; then
salida " ALCANZABLE: el sitio ha respondido desde esta conexión."
salida " Si aún así a ti te falla a ratos, repite esta prueba cada vez que"
salida " falle: lo que vale es la serie con fecha y hora, no una sola foto."
VEREDICTO=0
elif [ -z "$IP_A" ]; then
salida " NO RESUELVE EL NOMBRE: el dominio no se ha podido traducir a dirección."
salida " Prueba desde otra red (datos móviles, otra casa) para saber si es"
salida " cosa de tu operador o del dominio."
VEREDICTO=2
else
salida " NO ALCANZABLE: el nombre resuelve ($IP_A) pero la conexión no llega."
salida " Guarda este fichero: es exactamente la prueba que hay que adjuntar."
VEREDICTO=1
fi
# ------------------------------------------------------- qué hacer ahora
cat <<'TEXTO' | tee -a "$INFORME"
--- Y ahora, los otros dos pasos ---------------------------------
2 · MEDIR. Instala OONI Probe (libre, sin registro) y lanza la prueba
desde tu propia red: las mediciones se publican solas en OONI
Explorer. Una anomalía sola no prueba nada, pero suma.
App e instrucciones : https://ooni.org/install/
Mediciones públicas : https://explorer.ooni.org/
3 · DENUNCIAR (esto deja constancia; no hay caso documentado de una
reclamación individual que levante uno de estos bloqueos):
· Tu operador, primero, y pide respuesta por escrito.
· Oficina de Atención al Usuario de Telecomunicaciones (SETELECO):
https://usuariosteleco.digital.gob.es/reclamaciones/telefonia-e-internet/tramitacion-electronica
· AEPD, solo si hubo inspección de comunicaciones o tratamiento
indebido de datos:
https://www.aepd.es/
· CNMC, como coordinador de servicios digitales (DSA):
https://www.cnmc.es/sectores-que-regulamos/servicios-digitales-dsa/reclamaciones
Guarda también: capturas con la fecha visible, tu operador y, si puedes,
la misma prueba desde otra conexión.
TEXTO
salida ""
salida "Informe guardado en: $INFORME"
# Sin códigos ANSI a propósito: la salida se guarda tal cual y se puede pegar en
# un informe o un tique de incidencia sin que aparezcan escapes de color.
printf '\nInforme guardado en: %s\n' "$INFORME"
printf 'Código de salida: %s (0 responde · 1 no responde · 2 no resuelve)\n\n' "$VEREDICTO"
exit "$VEREDICTO"
| 1 | #!/usr/bin/env bash |
| 2 | # ============================================================================= |
| 3 | # demo-03 · La prueba del ciudadano |
| 4 | # ============================================================================= |
| 5 | # A qué punto del guion sirve: punto 15 |
| 6 | # «Qué puedes hacer tú: prueba, mide y denuncia» |
| 7 | # |
| 8 | # Qué hace: cuando un sitio legítimo te falla, guarda la prueba CON FECHA en un |
| 9 | # fichero fechado. Dominio, operador, hora exacta y el error tal cual. |
| 10 | # · curl → ¿responde? ¿con qué código? ¿cuánto tarda? y el error completo. |
| 11 | # · dig → ¿resuelve? ¿a qué direcciones? ¿con qué servidor de nombres? |
| 12 | # · whois → ¿de quién es esa dirección? (si es de un rango compartido, no se |
| 13 | # ha caído «tu» servidor: se ha caído un vector que usan cientos de |
| 14 | # miles de sitios). Si no hay `whois` instalado, se usa RDAP con |
| 15 | # `curl`, que da la misma información sin instalar nada. |
| 16 | # |
| 17 | # Uso: |
| 18 | # ./demo-03-prueba-del-ciudadano.sh <dominio> [operador] |
| 19 | # ./demo-03-prueba-del-ciudadano.sh tudominio.example "Mi operador" |
| 20 | # |
| 21 | # El informe se guarda en: |
| 22 | # $PRUEBAS_DIR/<AAAA-MM-DD_HHMMSS>-prueba-<dominio>.txt |
| 23 | # (por defecto $PRUEBAS_DIR=/tmp/opencode/pruebas-ciudadano) |
| 24 | # |
| 25 | # Códigos de salida (para que sirva también en un cron o en un script): |
| 26 | # 0 = el sitio responde |
| 27 | # 1 = el sitio NO responde (conexión, TLS o tiempo agotado) |
| 28 | # 2 = el nombre no resuelve |
| 29 | # 3 = no se ha podido escribir el informe |
| 30 | # |
| 31 | # Estado: VERIFICADO en local (cuatro casos: responde, no resuelve, no responde |
| 32 | # y bloqueo detectado). Salidas reales en `verificacion/demo-03-*.txt`. |
| 33 | # |
| 34 | # Seguridad de la demo: solo lectura. Nada de `ssh`, nada de tocar servidores |
| 35 | # ajenos, nada que modifique el sistema. El único tráfico que sale es el de tus |
| 36 | # propias consultas DNS/HTTP, que es justo lo que se está probando. |
| 37 | # ============================================================================= |
| 38 | |
| 39 | set -u |
| 40 | |
| 41 | DOMINIO="${1:-example.com}" |
| 42 | OPERADOR="${2:-${OPERADOR:-(anota aquí tu operador: Movistar, Vodafone, Digi…)}}" |
| 43 | DESTINO="${PRUEBAS_DIR:-/tmp/opencode/pruebas-ciudadano}" |
| 44 | |
| 45 | if [ "$DOMINIO" = "-h" ] || [ "$DOMINIO" = "--help" ]; then |
| 46 | sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//' |
| 47 | exit 0 |
| 48 | fi |
| 49 | |
| 50 | mkdir -p "$DESTINO" || { printf 'no se puede crear %s\n' "$DESTINO" >&2; exit 3; } |
| 51 | FECHA="$(date +%Y-%m-%d_%H%M%S)" |
| 52 | INFORME="$DESTINO/${FECHA}-prueba-${DOMINIO}.txt" |
| 53 | : > "$INFORME" || exit 3 |
| 54 | |
| 55 | ULTIMO_RC=0 |
| 56 | salida() { printf '%s\n' "$*" | tee -a "$INFORME"; } |
| 57 | orden() { printf '\n$ %s\n' "$*" | tee -a "$INFORME"; } |
| 58 | run() { # run <comando…> → lanza, guarda la salida y el código, nunca aborta |
| 59 | local cabecera salida_cmd rc=0 |
| 60 | cabecera="$(printf '$'; for a in "$@"; do |
| 61 | case "$a" in |
| 62 | *" "*) printf " '%s'" "$a" ;; |
| 63 | *) printf ' %s' "$a" ;; |
| 64 | esac |
| 65 | done)" |
| 66 | printf '\n%s\n' "$cabecera" | tee -a "$INFORME" |
| 67 | salida_cmd="$("$@" 2>&1)" || rc=$? |
| 68 | printf '%s\n' "$salida_cmd" | tee -a "$INFORME" |
| 69 | if [ "$rc" -ne 0 ]; then printf '[exit %s]\n' "$rc" | tee -a "$INFORME"; fi |
| 70 | ULTIMO_RC=$rc |
| 71 | return 0 |
| 72 | } |
| 73 | |
| 74 | # ------------------------------------------------------------------ cabecera |
| 75 | salida "================================================================" |
| 76 | salida " PRUEBA DE ACCESO — prueba del ciudadano" |
| 77 | salida "================================================================" |
| 78 | salida " Dominio : $DOMINIO" |
| 79 | salida " Operador : $OPERADOR" |
| 80 | salida " Fecha : $(date -Is)" |
| 81 | salida " Equipo : $(hostname) · $(uname -sr)" |
| 82 | salida "================================================================" |
| 83 | salida "" |
| 84 | salida "Nota: esta prueba NO dice quién bloquea ni por qué. Solo deja constancia" |
| 85 | salida "fechada de lo que ha pasado desde tu conexión. Una prueba, o varias, es" |
| 86 | salida "lo que se adjunta a una reclamación." |
| 87 | |
| 88 | # --------------------------------------------------------------- 1 · curl |
| 89 | salida "" |
| 90 | salida "--- 1 · ¿El sitio responde? -------------------------------------" |
| 91 | if command -v curl >/dev/null 2>&1; then |
| 92 | run curl -sS -o /dev/null \ |
| 93 | --connect-timeout 10 --max-time 20 \ |
| 94 | -w 'HTTP %{http_code} · connect %{time_connect}s · TLS %{time_appconnect}s · total %{time_total}s · IP destino %{remote_ip}\n' \ |
| 95 | "https://$DOMINIO/" |
| 96 | RC_CURL=$ULTIMO_RC |
| 97 | orden "curl -v https://$DOMINIO/ # el error tal cual, para adjuntar" |
| 98 | curl -v -o /dev/null --connect-timeout 10 --max-time 20 "https://$DOMINIO/" 2>&1 \ |
| 99 | | tee -a "$INFORME" |
| 100 | else |
| 101 | salida "curl no está instalado: sin esta parte no hay prueba de acceso." |
| 102 | RC_CURL=127 |
| 103 | fi |
| 104 | |
| 105 | # --------------------------------------------------------------- 2 · dig |
| 106 | salida "" |
| 107 | salida "--- 2 · ¿Resuelve el nombre? ------------------------------------" |
| 108 | IP_A="" |
| 109 | if command -v dig >/dev/null 2>&1; then |
| 110 | run dig "$DOMINIO" A |
| 111 | IP_A="$(dig +short A "$DOMINIO" | grep -E '^[0-9.]+$' | head -1)" |
| 112 | run dig +short "$DOMINIO" AAAA |
| 113 | run dig +short "$DOMINIO" NS |
| 114 | if [ -n "$IP_A" ]; then |
| 115 | salida "" |
| 116 | salida "Primera dirección IPv4 anotada: $IP_A" |
| 117 | fi |
| 118 | else |
| 119 | salida "dig no está instalado (paquete bind-utils / dnsutils)." |
| 120 | fi |
| 121 | |
| 122 | # --------------------------------------------------------------- 3 · whois |
| 123 | salida "" |
| 124 | salida "--- 3 · ¿De quién es esa dirección? -----------------------------" |
| 125 | salida "(si es de un rango compartido, no está caído «tu» servidor:" |
| 126 | salida " está caído un vector que usan cientos de miles de sitios)" |
| 127 | if [ -z "$IP_A" ]; then |
| 128 | salida "Sin dirección IPv4 no hay nada que consultar." |
| 129 | elif command -v whois >/dev/null 2>&1; then |
| 130 | run whois "$IP_A" |
| 131 | else |
| 132 | salida "No hay 'whois' instalado en este equipo. Alternativa sin instalar nada:" |
| 133 | salida "RDAP, el registro de direcciones que habla HTTP." |
| 134 | if command -v curl >/dev/null 2>&1; then |
| 135 | orden "curl -sSL https://rdap.org/ip/$IP_A" |
| 136 | RDAP="$(curl -sSL --connect-timeout 10 --max-time 20 "https://rdap.org/ip/$IP_A" 2>&1)" \ |
| 137 | || salida "(la consulta RDAP ha fallado)" |
| 138 | if command -v jq >/dev/null 2>&1 && printf '%s' "$RDAP" | jq -e . >/dev/null 2>&1; then |
| 139 | printf '%s' "$RDAP" | jq -r ' |
| 140 | " titular : " + (.name // .handle // (.entities[0].handle // "?")), |
| 141 | " registro : " + (.handle // "?"), |
| 142 | " país : " + (.country // "?"), |
| 143 | " rango : " + ( |
| 144 | if .["cidr0_cidrs"] then |
| 145 | (.["cidr0_cidrs"][0] | "\(.v4prefix // .v6prefix)/\(.length)") |
| 146 | elif .startAddress then "\(.startAddress) - \(.endAddress)" |
| 147 | else "?" end |
| 148 | ) |
| 149 | ' | tee -a "$INFORME" |
| 150 | else |
| 151 | printf '%s\n' "$RDAP" | head -40 | tee -a "$INFORME" |
| 152 | fi |
| 153 | fi |
| 154 | fi |
| 155 | |
| 156 | # ------------------------------------------------------------- veredicto |
| 157 | salida "" |
| 158 | salida "--- Resultado ---------------------------------------------------" |
| 159 | if [ -n "$IP_A" ] && [ "${RC_CURL:-1}" -eq 0 ]; then |
| 160 | salida " ALCANZABLE: el sitio ha respondido desde esta conexión." |
| 161 | salida " Si aún así a ti te falla a ratos, repite esta prueba cada vez que" |
| 162 | salida " falle: lo que vale es la serie con fecha y hora, no una sola foto." |
| 163 | VEREDICTO=0 |
| 164 | elif [ -z "$IP_A" ]; then |
| 165 | salida " NO RESUELVE EL NOMBRE: el dominio no se ha podido traducir a dirección." |
| 166 | salida " Prueba desde otra red (datos móviles, otra casa) para saber si es" |
| 167 | salida " cosa de tu operador o del dominio." |
| 168 | VEREDICTO=2 |
| 169 | else |
| 170 | salida " NO ALCANZABLE: el nombre resuelve ($IP_A) pero la conexión no llega." |
| 171 | salida " Guarda este fichero: es exactamente la prueba que hay que adjuntar." |
| 172 | VEREDICTO=1 |
| 173 | fi |
| 174 | |
| 175 | # ------------------------------------------------------- qué hacer ahora |
| 176 | cat <<'TEXTO' | tee -a "$INFORME" |
| 177 | |
| 178 | --- Y ahora, los otros dos pasos --------------------------------- |
| 179 | 2 · MEDIR. Instala OONI Probe (libre, sin registro) y lanza la prueba |
| 180 | desde tu propia red: las mediciones se publican solas en OONI |
| 181 | Explorer. Una anomalía sola no prueba nada, pero suma. |
| 182 | App e instrucciones : https://ooni.org/install/ |
| 183 | Mediciones públicas : https://explorer.ooni.org/ |
| 184 | |
| 185 | 3 · DENUNCIAR (esto deja constancia; no hay caso documentado de una |
| 186 | reclamación individual que levante uno de estos bloqueos): |
| 187 | · Tu operador, primero, y pide respuesta por escrito. |
| 188 | · Oficina de Atención al Usuario de Telecomunicaciones (SETELECO): |
| 189 | https://usuariosteleco.digital.gob.es/reclamaciones/telefonia-e-internet/tramitacion-electronica |
| 190 | · AEPD, solo si hubo inspección de comunicaciones o tratamiento |
| 191 | indebido de datos: |
| 192 | https://www.aepd.es/ |
| 193 | · CNMC, como coordinador de servicios digitales (DSA): |
| 194 | https://www.cnmc.es/sectores-que-regulamos/servicios-digitales-dsa/reclamaciones |
| 195 | |
| 196 | Guarda también: capturas con la fecha visible, tu operador y, si puedes, |
| 197 | la misma prueba desde otra conexión. |
| 198 | TEXTO |
| 199 | |
| 200 | salida "" |
| 201 | salida "Informe guardado en: $INFORME" |
| 202 | # Sin códigos ANSI a propósito: la salida se guarda tal cual y se puede pegar en |
| 203 | # un informe o un tique de incidencia sin que aparezcan escapes de color. |
| 204 | printf '\nInforme guardado en: %s\n' "$INFORME" |
| 205 | printf 'Código de salida: %s (0 responde · 1 no responde · 2 no resuelve)\n\n' "$VEREDICTO" |
| 206 | exit "$VEREDICTO" |
| 207 |
demo-04-borde-haproxy.sh
· 9.4 KiB · Bash
Brut
#!/usr/bin/env bash
# =============================================================================
# demo-04 · El borde con HAProxy
# =============================================================================
# A qué punto del guion sirve: puntos 16, 17, 18 y 19
# · 16 «HAProxy en bare metal» → el borde corre en el HOST,
# fuera del contenedor.
# · 17 «Directo al 443» → el puerto privilegiado lo abre
# HAProxy, en el metal.
# · 18 «Traefik se levanta en el 8443» → el contenedor solo publica un
# puerto alto.
# · 19 «Y no necesito reglas especiales» → sin ip_unprivileged_port_start,
# sin setcap, sin iptables.
#
# Qué se ve en pantalla:
# 1. `config/haproxy.cfg` entero, con las tres líneas clave señaladas.
# 2. El recorrido: :443 → HAProxy → `send-proxy-v2` → Traefik :8443.
# 3. La validación de sintaxis de verdad, con `haproxy -c` y un control
# negativo (un fichero roto a propósito) para demostrar que el chequeo
# no dice «OK» a todo.
#
# Estado: VERIFICADO en local. La validación se ejecuta con HAProxy 3.4.7
# dentro de un contenedor EFÍMERO (`--rm`, sin puertos publicados y sin tocar
# el host). Salida real en `verificacion/demo-04-salida.txt`.
# En esta máquina NO hay binario `haproxy` ni `docker` instalados: por eso la
# validación va por contenedor y ese camino es el que está verificado.
#
# Si no hay ni `haproxy` ni `podman` ni `docker`, el script NO falla: avisa de
# que la sintaxis queda sin validar y muestra el comando exacto para hacerlo.
# =============================================================================
set -u
AQUI="$(cd "$(dirname "$0")" && pwd)"
CFG="$AQUI/config/haproxy.cfg"
# Imagen del validador. Se mira primero la caché local y, si no hay ninguna, se
# descarga. Son las mismas versiones que el binario oficial de HAProxy:
# · docker.io/library/haproxy:lts-alpine → la canónica
# · mirror.gcr.io/library/haproxy:lts-alpine → espejo público (el Hub devolvió
# error de autenticación al preparar este material, así que el espejo es lo
# que acabó funcionando)
# · docker.io/library/haproxy:latest → por si ya la tienes por casa
CANDIDATAS=(
"docker.io/library/haproxy:lts-alpine"
"mirror.gcr.io/library/haproxy:lts-alpine"
"docker.io/library/haproxy:latest"
)
IMAGEN="${HAPROXY_IMAGEN:-${CANDIDATAS[0]}}"
TMP="${TMPDIR:-/tmp/opencode}"
resolver_imagen() { # elige la primera imagen disponible (caché primero, luego descarga)
local img
for img in "${CANDIDATAS[@]}"; do
if podman image exists "$img" >/dev/null 2>&1; then IMAGEN="$img"; return 0; fi
done
for img in "${CANDIDATAS[@]}"; do
printf ' (descargando %s…)\n' "$img"
if podman pull "$img" >/dev/null 2>&1; then IMAGEN="$img"; return 0; fi
done
return 1
}
titulo() { printf '\n\033[1m── %s\033[0m\n' "$1"; }
orden() { printf '\n\033[1m$ %s\033[0m\n' "$1"; }
nota() { printf ' ↳ %s\n' "$1"; }
ok() { printf ' \033[1m✔ %s\033[0m\n' "$1"; }
fallo() { printf ' \033[1m✘ %s\033[0m\n' "$1"; }
rc_final=0
printf '\033[1mEl borde en bare metal\033[0m (puntos 16, 17, 18 y 19)\n'
# -----------------------------------------------------------------------------
titulo "1 · El fichero del borde"
# -----------------------------------------------------------------------------
orden "cat config/haproxy.cfg"
cat "$CFG"
# -----------------------------------------------------------------------------
titulo "2 · Las cuatro líneas que sostienen el diseño"
# -----------------------------------------------------------------------------
orden "grep -nE 'mode tcp|bind \\*:443|send-proxy-v2|127.0.0.1:8443' config/haproxy.cfg"
grep -nE 'mode tcp|bind \*:443|send-proxy-v2|127.0.0.1:8443' "$CFG"
nota "'bind *:443' → el puerto privilegiado lo abre el HOST (punto 17)."
nota "'mode tcp' → no descifra: pasa la conexión tal cual."
nota "'send-proxy-v2' → avisa al siguiente salto de quién llama."
nota "'127.0.0.1:8443' → Traefik, en rootless, en un puerto alto (punto 18)."
# Comprobación de que están de verdad (no solo de que el grep los encuentre).
for patron in 'bind \*:443' 'send-proxy-v2' '127\.0\.0\.1:8443'; do
if grep -qE "$patron" "$CFG"; then
ok "presente: $patron"
else
fallo "falta: $patron"
rc_final=1
fi
done
# -----------------------------------------------------------------------------
titulo "3 · Los tres apaños que NO hacen falta (punto 19)"
# -----------------------------------------------------------------------------
orden "grep -nE 'ip_unprivileged_port_start|setcap|iptables|nftables' config/haproxy.cfg # solo aparecen en los comentarios"
if grep -nE 'ip_unprivileged_port_start|setcap|iptables|nftables' "$CFG"; then
nota "aparecen mencionados en los comentarios, para explicar por qué no se usan;"
nota "ninguno está como directiva activa (en HAProxy no existen como directivas)."
else
nota "ni siquiera se mencionan."
fi
nota "El motivo, en una frase: el contenedor no abre ningún puerto < 1024,"
nota "así que no hay nada que habilitar dentro del espacio de nombres."
# -----------------------------------------------------------------------------
titulo "4 · Validar la sintaxis con HAProxy de verdad"
# -----------------------------------------------------------------------------
validar() { # validar <fichero> → 0 si HAProxy acepta la configuración
if command -v haproxy >/dev/null 2>&1; then
haproxy -c -f "$1"
elif command -v podman >/dev/null 2>&1; then
podman run --rm -v "$1:/usr/local/etc/haproxy/haproxy.cfg:ro" \
"$IMAGEN" haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg
elif command -v docker >/dev/null 2>&1; then
docker run --rm -v "$1:/usr/local/etc/haproxy/haproxy.cfg:ro" \
"$IMAGEN" haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg
else
return 127
fi
}
if command -v haproxy >/dev/null 2>&1; then
orden "haproxy -v"
haproxy -v | head -2
VALIDADOR="haproxy (binario del sistema)"
elif command -v podman >/dev/null 2>&1; then
[ -n "${HAPROXY_IMAGEN:-}" ] || resolver_imagen || true
if podman run --rm "$IMAGEN" haproxy -v >/dev/null 2>&1; then
orden "podman run --rm $IMAGEN haproxy -v"
podman run --rm "$IMAGEN" haproxy -v 2>&1 | head -2
VALIDADOR="HAProxy en contenedor efímero (podman)"
nota "El contenedor se borra solo (--rm): no deja nada en el host."
nota "Imagen usada: $IMAGEN"
else
VALIDADOR=""
fi
elif command -v docker >/dev/null 2>&1; then
if docker run --rm "$IMAGEN" haproxy -v >/dev/null 2>&1; then
orden "docker run --rm $IMAGEN haproxy -v"
docker run --rm "$IMAGEN" haproxy -v 2>&1 | head -2
VALIDADOR="HAProxy en contenedor efímero (docker)"
else
VALIDADOR=""
fi
else
VALIDADOR=""
fi
if [ -n "$VALIDADOR" ]; then
orden "haproxy -c -f config/haproxy.cfg"
if validar "$CFG"; then
ok "configuración válida (exit 0). Validador: $VALIDADOR"
else
fallo "HAProxy NO acepta la configuración."
rc_final=1
fi
# --- control negativo: si el chequeo dijera «OK» a todo, no valdría nada ---
orden "control negativo: una copia con 'send-proxy-v9' (no existe)"
ROTO="$(mktemp "$TMP/haproxy-roto-XXXXXX.cfg")"
sed 's/send-proxy-v2/send-proxy-v9/' "$CFG" > "$ROTO"
if validar "$ROTO" >/dev/null 2>&1; then
fallo "ha aceptado una directiva inventada: la validación no vale."
rc_final=1
else
ok "la copia rota se rechaza → el chequeo es real (exit != 0)."
fi
rm -f "$ROTO"
else
nota "No hay 'haproxy', ni 'podman', ni 'docker' en esta máquina:"
nota "la sintaxis queda SIN VALIDAR. Para validarla, en cualquier equipo:"
nota " podman run --rm -v \"\$PWD/config/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro\" \\"
nota " $IMAGEN haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg"
fi
# -----------------------------------------------------------------------------
titulo "5 · El recorrido, para contarlo en voz alta"
# -----------------------------------------------------------------------------
cat <<'TEXTO'
visitante ──:443──► HAProxy (HOST, servicio del sistema)
│ mode tcp: no descifra nada
│ send-proxy-v2: antepone quién llama
▼
Traefik :8443 (rootless, en su contenedor)
│ proxyProtocol.trustedIPs: solo ese borde
│ forwardedHeaders: lee la IP de verdad
▼
tu web, tus logs y la analítica
· El 443 no lo pelea el contenedor: lo abre el host. Por eso no hacen falta
ip_unprivileged_port_start, ni setcap, ni reglas de iptables.
· Sin certificados duplicados: HAProxy no termina TLS, solo lo atraviesa.
· Cada cosa en la capa que le toca: el puerto en el metal, el enrutado en el
contenedor rootless.
TEXTO
printf '\n'
if [ "$rc_final" -eq 0 ]; then
printf '\033[1mResultado: el diseño del borde está completo y validado.\033[0m\n\n'
else
printf '\033[1mResultado: hay algo que revisar (ver arriba).\033[0m\n\n'
fi
exit "$rc_final"
| 1 | #!/usr/bin/env bash |
| 2 | # ============================================================================= |
| 3 | # demo-04 · El borde con HAProxy |
| 4 | # ============================================================================= |
| 5 | # A qué punto del guion sirve: puntos 16, 17, 18 y 19 |
| 6 | # · 16 «HAProxy en bare metal» → el borde corre en el HOST, |
| 7 | # fuera del contenedor. |
| 8 | # · 17 «Directo al 443» → el puerto privilegiado lo abre |
| 9 | # HAProxy, en el metal. |
| 10 | # · 18 «Traefik se levanta en el 8443» → el contenedor solo publica un |
| 11 | # puerto alto. |
| 12 | # · 19 «Y no necesito reglas especiales» → sin ip_unprivileged_port_start, |
| 13 | # sin setcap, sin iptables. |
| 14 | # |
| 15 | # Qué se ve en pantalla: |
| 16 | # 1. `config/haproxy.cfg` entero, con las tres líneas clave señaladas. |
| 17 | # 2. El recorrido: :443 → HAProxy → `send-proxy-v2` → Traefik :8443. |
| 18 | # 3. La validación de sintaxis de verdad, con `haproxy -c` y un control |
| 19 | # negativo (un fichero roto a propósito) para demostrar que el chequeo |
| 20 | # no dice «OK» a todo. |
| 21 | # |
| 22 | # Estado: VERIFICADO en local. La validación se ejecuta con HAProxy 3.4.7 |
| 23 | # dentro de un contenedor EFÍMERO (`--rm`, sin puertos publicados y sin tocar |
| 24 | # el host). Salida real en `verificacion/demo-04-salida.txt`. |
| 25 | # En esta máquina NO hay binario `haproxy` ni `docker` instalados: por eso la |
| 26 | # validación va por contenedor y ese camino es el que está verificado. |
| 27 | # |
| 28 | # Si no hay ni `haproxy` ni `podman` ni `docker`, el script NO falla: avisa de |
| 29 | # que la sintaxis queda sin validar y muestra el comando exacto para hacerlo. |
| 30 | # ============================================================================= |
| 31 | |
| 32 | set -u |
| 33 | |
| 34 | AQUI="$(cd "$(dirname "$0")" && pwd)" |
| 35 | CFG="$AQUI/config/haproxy.cfg" |
| 36 | # Imagen del validador. Se mira primero la caché local y, si no hay ninguna, se |
| 37 | # descarga. Son las mismas versiones que el binario oficial de HAProxy: |
| 38 | # · docker.io/library/haproxy:lts-alpine → la canónica |
| 39 | # · mirror.gcr.io/library/haproxy:lts-alpine → espejo público (el Hub devolvió |
| 40 | # error de autenticación al preparar este material, así que el espejo es lo |
| 41 | # que acabó funcionando) |
| 42 | # · docker.io/library/haproxy:latest → por si ya la tienes por casa |
| 43 | CANDIDATAS=( |
| 44 | "docker.io/library/haproxy:lts-alpine" |
| 45 | "mirror.gcr.io/library/haproxy:lts-alpine" |
| 46 | "docker.io/library/haproxy:latest" |
| 47 | ) |
| 48 | IMAGEN="${HAPROXY_IMAGEN:-${CANDIDATAS[0]}}" |
| 49 | TMP="${TMPDIR:-/tmp/opencode}" |
| 50 | |
| 51 | resolver_imagen() { # elige la primera imagen disponible (caché primero, luego descarga) |
| 52 | local img |
| 53 | for img in "${CANDIDATAS[@]}"; do |
| 54 | if podman image exists "$img" >/dev/null 2>&1; then IMAGEN="$img"; return 0; fi |
| 55 | done |
| 56 | for img in "${CANDIDATAS[@]}"; do |
| 57 | printf ' (descargando %s…)\n' "$img" |
| 58 | if podman pull "$img" >/dev/null 2>&1; then IMAGEN="$img"; return 0; fi |
| 59 | done |
| 60 | return 1 |
| 61 | } |
| 62 | |
| 63 | titulo() { printf '\n\033[1m── %s\033[0m\n' "$1"; } |
| 64 | orden() { printf '\n\033[1m$ %s\033[0m\n' "$1"; } |
| 65 | nota() { printf ' ↳ %s\n' "$1"; } |
| 66 | ok() { printf ' \033[1m✔ %s\033[0m\n' "$1"; } |
| 67 | fallo() { printf ' \033[1m✘ %s\033[0m\n' "$1"; } |
| 68 | |
| 69 | rc_final=0 |
| 70 | |
| 71 | printf '\033[1mEl borde en bare metal\033[0m (puntos 16, 17, 18 y 19)\n' |
| 72 | |
| 73 | # ----------------------------------------------------------------------------- |
| 74 | titulo "1 · El fichero del borde" |
| 75 | # ----------------------------------------------------------------------------- |
| 76 | orden "cat config/haproxy.cfg" |
| 77 | cat "$CFG" |
| 78 | |
| 79 | # ----------------------------------------------------------------------------- |
| 80 | titulo "2 · Las cuatro líneas que sostienen el diseño" |
| 81 | # ----------------------------------------------------------------------------- |
| 82 | orden "grep -nE 'mode tcp|bind \\*:443|send-proxy-v2|127.0.0.1:8443' config/haproxy.cfg" |
| 83 | grep -nE 'mode tcp|bind \*:443|send-proxy-v2|127.0.0.1:8443' "$CFG" |
| 84 | nota "'bind *:443' → el puerto privilegiado lo abre el HOST (punto 17)." |
| 85 | nota "'mode tcp' → no descifra: pasa la conexión tal cual." |
| 86 | nota "'send-proxy-v2' → avisa al siguiente salto de quién llama." |
| 87 | nota "'127.0.0.1:8443' → Traefik, en rootless, en un puerto alto (punto 18)." |
| 88 | |
| 89 | # Comprobación de que están de verdad (no solo de que el grep los encuentre). |
| 90 | for patron in 'bind \*:443' 'send-proxy-v2' '127\.0\.0\.1:8443'; do |
| 91 | if grep -qE "$patron" "$CFG"; then |
| 92 | ok "presente: $patron" |
| 93 | else |
| 94 | fallo "falta: $patron" |
| 95 | rc_final=1 |
| 96 | fi |
| 97 | done |
| 98 | |
| 99 | # ----------------------------------------------------------------------------- |
| 100 | titulo "3 · Los tres apaños que NO hacen falta (punto 19)" |
| 101 | # ----------------------------------------------------------------------------- |
| 102 | orden "grep -nE 'ip_unprivileged_port_start|setcap|iptables|nftables' config/haproxy.cfg # solo aparecen en los comentarios" |
| 103 | if grep -nE 'ip_unprivileged_port_start|setcap|iptables|nftables' "$CFG"; then |
| 104 | nota "aparecen mencionados en los comentarios, para explicar por qué no se usan;" |
| 105 | nota "ninguno está como directiva activa (en HAProxy no existen como directivas)." |
| 106 | else |
| 107 | nota "ni siquiera se mencionan." |
| 108 | fi |
| 109 | nota "El motivo, en una frase: el contenedor no abre ningún puerto < 1024," |
| 110 | nota "así que no hay nada que habilitar dentro del espacio de nombres." |
| 111 | |
| 112 | # ----------------------------------------------------------------------------- |
| 113 | titulo "4 · Validar la sintaxis con HAProxy de verdad" |
| 114 | # ----------------------------------------------------------------------------- |
| 115 | validar() { # validar <fichero> → 0 si HAProxy acepta la configuración |
| 116 | if command -v haproxy >/dev/null 2>&1; then |
| 117 | haproxy -c -f "$1" |
| 118 | elif command -v podman >/dev/null 2>&1; then |
| 119 | podman run --rm -v "$1:/usr/local/etc/haproxy/haproxy.cfg:ro" \ |
| 120 | "$IMAGEN" haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg |
| 121 | elif command -v docker >/dev/null 2>&1; then |
| 122 | docker run --rm -v "$1:/usr/local/etc/haproxy/haproxy.cfg:ro" \ |
| 123 | "$IMAGEN" haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg |
| 124 | else |
| 125 | return 127 |
| 126 | fi |
| 127 | } |
| 128 | |
| 129 | if command -v haproxy >/dev/null 2>&1; then |
| 130 | orden "haproxy -v" |
| 131 | haproxy -v | head -2 |
| 132 | VALIDADOR="haproxy (binario del sistema)" |
| 133 | elif command -v podman >/dev/null 2>&1; then |
| 134 | [ -n "${HAPROXY_IMAGEN:-}" ] || resolver_imagen || true |
| 135 | if podman run --rm "$IMAGEN" haproxy -v >/dev/null 2>&1; then |
| 136 | orden "podman run --rm $IMAGEN haproxy -v" |
| 137 | podman run --rm "$IMAGEN" haproxy -v 2>&1 | head -2 |
| 138 | VALIDADOR="HAProxy en contenedor efímero (podman)" |
| 139 | nota "El contenedor se borra solo (--rm): no deja nada en el host." |
| 140 | nota "Imagen usada: $IMAGEN" |
| 141 | else |
| 142 | VALIDADOR="" |
| 143 | fi |
| 144 | elif command -v docker >/dev/null 2>&1; then |
| 145 | if docker run --rm "$IMAGEN" haproxy -v >/dev/null 2>&1; then |
| 146 | orden "docker run --rm $IMAGEN haproxy -v" |
| 147 | docker run --rm "$IMAGEN" haproxy -v 2>&1 | head -2 |
| 148 | VALIDADOR="HAProxy en contenedor efímero (docker)" |
| 149 | else |
| 150 | VALIDADOR="" |
| 151 | fi |
| 152 | else |
| 153 | VALIDADOR="" |
| 154 | fi |
| 155 | |
| 156 | if [ -n "$VALIDADOR" ]; then |
| 157 | orden "haproxy -c -f config/haproxy.cfg" |
| 158 | if validar "$CFG"; then |
| 159 | ok "configuración válida (exit 0). Validador: $VALIDADOR" |
| 160 | else |
| 161 | fallo "HAProxy NO acepta la configuración." |
| 162 | rc_final=1 |
| 163 | fi |
| 164 | |
| 165 | # --- control negativo: si el chequeo dijera «OK» a todo, no valdría nada --- |
| 166 | orden "control negativo: una copia con 'send-proxy-v9' (no existe)" |
| 167 | ROTO="$(mktemp "$TMP/haproxy-roto-XXXXXX.cfg")" |
| 168 | sed 's/send-proxy-v2/send-proxy-v9/' "$CFG" > "$ROTO" |
| 169 | if validar "$ROTO" >/dev/null 2>&1; then |
| 170 | fallo "ha aceptado una directiva inventada: la validación no vale." |
| 171 | rc_final=1 |
| 172 | else |
| 173 | ok "la copia rota se rechaza → el chequeo es real (exit != 0)." |
| 174 | fi |
| 175 | rm -f "$ROTO" |
| 176 | else |
| 177 | nota "No hay 'haproxy', ni 'podman', ni 'docker' en esta máquina:" |
| 178 | nota "la sintaxis queda SIN VALIDAR. Para validarla, en cualquier equipo:" |
| 179 | nota " podman run --rm -v \"\$PWD/config/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro\" \\" |
| 180 | nota " $IMAGEN haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg" |
| 181 | fi |
| 182 | |
| 183 | # ----------------------------------------------------------------------------- |
| 184 | titulo "5 · El recorrido, para contarlo en voz alta" |
| 185 | # ----------------------------------------------------------------------------- |
| 186 | cat <<'TEXTO' |
| 187 | visitante ──:443──► HAProxy (HOST, servicio del sistema) |
| 188 | │ mode tcp: no descifra nada |
| 189 | │ send-proxy-v2: antepone quién llama |
| 190 | ▼ |
| 191 | Traefik :8443 (rootless, en su contenedor) |
| 192 | │ proxyProtocol.trustedIPs: solo ese borde |
| 193 | │ forwardedHeaders: lee la IP de verdad |
| 194 | ▼ |
| 195 | tu web, tus logs y la analítica |
| 196 | |
| 197 | · El 443 no lo pelea el contenedor: lo abre el host. Por eso no hacen falta |
| 198 | ip_unprivileged_port_start, ni setcap, ni reglas de iptables. |
| 199 | · Sin certificados duplicados: HAProxy no termina TLS, solo lo atraviesa. |
| 200 | · Cada cosa en la capa que le toca: el puerto en el metal, el enrutado en el |
| 201 | contenedor rootless. |
| 202 | TEXTO |
| 203 | |
| 204 | printf '\n' |
| 205 | if [ "$rc_final" -eq 0 ]; then |
| 206 | printf '\033[1mResultado: el diseño del borde está completo y validado.\033[0m\n\n' |
| 207 | else |
| 208 | printf '\033[1mResultado: hay algo que revisar (ver arriba).\033[0m\n\n' |
| 209 | fi |
| 210 | exit "$rc_final" |
| 211 |