Last active 14 hours ago

Scripts de demostración del episodio «Podman, IPs reales y La Liga» (atareao con Linux): diagnóstico del motor de contenedores, la prueba del ciudadano ante un bloqueo, la corrección de Traefik y Umami, y el borde con HAProxy.

atareao's Avatar atareao revised this gist 14 hours ago. Go to revision

4 files changed, 735 insertions

demo-01-diagnostico-de-tu-motor.sh(file created)

@@ -0,0 +1,156 @@
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

demo-02-correcciones-traefik-y-umami.sh(file created)

@@ -0,0 +1,163 @@
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"

demo-03-prueba-del-ciudadano.sh(file created)

@@ -0,0 +1,206 @@
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"

demo-04-borde-haproxy.sh(file created)

@@ -0,0 +1,210 @@
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"
Newer Older