From 0f3b1375789a1487c8437037524f49d7fddb5c19 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 12 Sep 2026 01:49:02 +0000 Subject: [PATCH] =?UTF-8?q?censar:=20leer=20la=20config=20del=20servidor?= =?UTF-8?q?=20web=20=E2=80=94=20cinco=20sitios=20sirven=20un=20`root`=20qu?= =?UTF-8?q?e=20NO=20EXISTE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Eso lo dice el FICHERO DE CONFIGURACIÓN, y leerlo no toca al origen: ni una petición, ni riesgo de fail2ban. Para leerlo se agregó un LECTOR de Caddy al centro (`formatos/caddy.py` ya tenía el escritor), y el censo lo usa en vez de tener su propio parser a medias — los de nginx y apache ya existían, y dos parsers del mismo formato es cómo se separan sin que nadie lo note. Cuarto par de la familia web. Sobre el Caddyfile real de gioser, cinco sitios apuntan a un `root` que no existe — y son justo los que devolvían los 502 que en su día hicieron que el censo SE BANEARA A SÍ MISMO al sondearlos: aura.gioser.net → /var/www/aura_frontend · sigma → /var/www/sigma/frontend summa → /var/www/summa/frontend · kosmofono → … · dev.summa → … Es evidencia MÁS FUERTE que el DNS: no hay nada que servir, devuelve 502 resuelva donde resuelva. Va como recomendación `muere` con la ruta y el fichero donde está el bloque. **Y sirve para lo contrario, que es donde el aviso hacía daño.** Un directorio que la config SÍ referencia no es huérfano: el plan marcaba `/var/www/git-tawasuyu` como «nadie lo recuerda» estando servido, y ese aviso aplicado tira `git.tawasuyu.net`. Dos bugs propios, los dos encontrados contra el fichero real y no sobre un ejemplo mío: · El `root` de ese sitio vive DENTRO de un `handle`, y yo saltaba los bloques anidados enteros por no saber modelarlos. Que el pivote no sepa MODELAR algo no es razón para no VERLO: el bloque sigue marcado SIN-TRADUCIR pero sus `root`/`reverse_proxy` se leen. Con eso aparecieron dos raíces ausentes más, también anidadas. · Detectar la cabecera de sitio con una lista negra de directivas dejaba pasar `log { output file … {` y el snippet `(acceso) {`: DOS dominios inventados que el censo habría puesto a decidir. La regla que aguanta es positiva — todos los tokens de la cabecera tienen que PARECER una dirección. Sin regresión: `nginx → caddy` y `apache → caddy` siguen dando `Valid configuration` con el caddy del corpus. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x --- docs/29-mudanza.md | 42 ++++++++++ scripts/mudanza/censar.py | 77 ++++++++++++++++- scripts/mudanza/formatos/caddy.py | 134 +++++++++++++++++++++++++++++- scripts/mudanza/planear.py | 37 +++++++-- 4 files changed, 279 insertions(+), 11 deletions(-) diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md index 84056dfb..2d598893 100644 --- a/docs/29-mudanza.md +++ b/docs/29-mudanza.md @@ -301,6 +301,48 @@ reconstruyen el servidor desde cero**: Ninguno de los tres es un log de lo que se hizo: los tres son entradas que se vuelven a ejecutar. Ésa es la diferencia entre mudar un servidor y poder mudarlo otra vez. +### 4.0 decies · Leer la config del servidor: un fósil se detecta SIN tocar la red + +La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Eso lo dice el +**fichero de configuración**, y leerlo no toca al origen — ni una petición, ni riesgo de fail2ban. + +Para leerlo se agregó un **lector de Caddy** al centro (`formatos/caddy.py`, que ya tenía el +escritor), y el censo lo usa en vez de tener su propio parser a medias: los lectores de nginx y +apache ya existían, y dos parsers del mismo formato es cómo se separan sin que nadie lo note. + +El resultado inmediato, sobre el Caddyfile real de gioser — **cinco sitios apuntan a un `root` que no +existe**, y son justo los que devolvían los 502 que en su día hicieron que el censo *se baneara a sí +mismo* al sondearlos por HTTP: + +``` + aura.gioser.net → /var/www/aura_frontend NO EXISTE + sigma.gioser.net → /var/www/sigma/frontend NO EXISTE + summa.gioser.net → /var/www/summa/frontend NO EXISTE + kosmofono.gioser.net → /var/www/kosmofono/frontend NO EXISTE + dev.summa.gioser.net → /var/www/summa-dev/frontend NO EXISTE +``` + +Es evidencia **más fuerte que el DNS**: no hay nada que servir, devuelve 502 resuelva donde resuelva. + +#### Y sirve para lo contrario, que es donde el aviso hacía daño + +Un directorio que la config **sí** referencia no es un huérfano, diga lo que diga su nombre. Sin +esto, el plan marcaba `/var/www/git-tawasuyu` como «nadie lo recuerda» estando servido — y ese aviso, +aplicado, **tira `git.tawasuyu.net`**. + +La primera versión del lector seguía fallando en eso, y el motivo es instructivo: el `root` de ese +sitio vive **dentro de un `handle`**, y yo saltaba los bloques anidados enteros por no saber +modelarlos. Que el pivote no sepa **modelar** algo no es razón para no **verlo**: ahora el bloque +sigue marcado SIN-TRADUCIR, pero sus `root` y `reverse_proxy` se leen. Con eso aparecieron dos raíces +ausentes más, que también estaban anidadas. + +#### Falsos sitios: una lista negra no alcanzaba + +Detectar la cabecera de un sitio descartando directivas conocidas (`handle {`, `route {`, `@bot {`) +dejaba pasar `log { output file … {` y el snippet `(acceso) {` — **dos dominios inventados** que el +censo habría puesto a decidir. La regla que aguanta es positiva: *todos* los tokens de la cabecera +tienen que **parecer una dirección**. + ### 4.0 nonies · La cadena entera, y el preflight que medía contra el sistema de ficheros equivocado `censar → planear → aplicar` corrió de punta a punta contra gioser: **59 pasos**, 20 ejecutables y 39 diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py index 6b1327e7..9a06142a 100755 --- a/scripts/mudanza/censar.py +++ b/scripts/mudanza/censar.py @@ -288,6 +288,75 @@ def declarados(t): return fuentes +# Config del servidor web → qué sitio apunta a qué directorio. Se lee con los LECTORES del centro de +# traducción (`formatos/`), no con un parser propio: ya existen para caddy, nginx y apache, y tener +# dos parsers del mismo formato es cómo se separan sin que nadie lo note. +_CONFS = [("/etc/caddy/Caddyfile", "caddy"), + ("/etc/nginx/nginx.conf", "nginx"), + ("/etc/nginx/sites-enabled/*", "nginx"), + ("/etc/apache2/sites-enabled/*", "apache"), + ("/etc/httpd/conf.d/*", "apache")] + + +def sitios_web(t): + """Los sitios declarados en la config, con su raíz y si esa raíz EXISTE. + + ── POR QUÉ ESTO NO LO PODÍA DECIR EL DNS ─────────────────────────────────────────────────── + La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Un bloque + cuyo `root` apunta a un directorio que NO existe es un fósil que devuelve 502 — y eso se lee del + fichero: **sin red, sin pedirle una página al servidor, sin arriesgar su fail2ban**. Medido en + gioser: 3 sitios (`aura`, `sigma`, `kosmofono`) apuntan a raíces borradas, lo que explica los + 502 que en su día hicieron que el censo se baneara a sí mismo al sondearlos por HTTP. + + Y sirve para lo contrario: un directorio que la config SÍ referencia no es un huérfano. Sin esto + el censo marcó `/var/www/git-tawasuyu` como «nadie lo recuerda» cuando es un sitio vivo.""" + import formatos + lectores, _ = formatos.cargar() + res = [] + for patron, fmt in _CONFS: + if fmt not in lectores: + continue + ficheros = [patron] + if "*" in patron: + out, ok = t.run(f"ls -1 {patron} 2>/dev/null") + ficheros = out.split() if ok and out.strip() else [] + for f in ficheros: + texto, ok = t.run(f"cat {f} 2>/dev/null") + if not ok or not texto.strip() or texto.startswith("(sonda falló"): + continue + try: + cfg = lectores[fmt][0](texto, f) + except Exception as e: + # Un parser que revienta NO puede tumbar el censo entero, pero tampoco callarse: + # sin esto uno cree que el servidor no declara sitios, que es lo contrario del dato. + res.append({"config": f, "formato": fmt, "nombres": "", "raiz": "", + "raiz_existe": "error", "detalle": f"no se pudo leer: {e}"[:120]}) + continue + for s in cfg.sitios: + # El `root` del sitio Y los de sus rutas anidadas: un `handle` con su propio `root` + # referencia disco igual, y no verlo es lo que hacía pasar por huérfano a un sitio vivo. + raices = [(s.raiz or "").rstrip("/")] + [ + a.valor.rstrip("/") for r in s.rutas for a in r.acciones if a.tipo == "archivos"] + for raiz in dict.fromkeys(x for x in raices if x) or [""]: + res.append({"config": f, "formato": fmt, + "nombres": " ".join(s.nombres), "raiz": raiz, + "raiz_existe": "", "detalle": ""}) + # Un solo comando para todas las raíces: una sonda por directorio sobre SSH es lentísima. + raices = sorted({r["raiz"] for r in res if r["raiz"]}) + if raices: + guion = "; ".join(f'[ -d {shlex.quote(d)} ] && echo "SI {d}" || echo "NO {d}"' for d in raices) + out, _ = t.run(guion) + estado = {} + for l in out.splitlines(): + p = l.split(None, 1) + if len(p) == 2: + estado[p[1]] = "si" if p[0] == "SI" else "no" + for r in res: + if r["raiz"]: + r["raiz_existe"] = estado.get(r["raiz"], "?") + return res + + def dominios(t): """Los dominios que la máquina DICE servir, contrastados con DNS y con un GET real. @@ -578,6 +647,11 @@ def emitir_toml(c): L.append(f"paquete = {toml_escape(s.get('paquete',''))}") L.append('decision = "" # muda | muere | (vacío = sin decidir)') L.append("") + for x in c.get("sitios", []): + L.append("[[sitio]]") + for k, v in x.items(): + L.append(f"{k} = {toml_escape(v)}") + L.append("") for x in c.get("contenido", []): L.append("[[contenido]]") for k, v in x.items(): @@ -700,6 +774,7 @@ def main(): pts = puertos(t) nombres = dominios(t) cont = contenido_web(t) + sitios = sitios_web(t) sondear = (a.probe or a.probe_dns or bool(a.host)) and not a.no_probe http_ok = not a.probe_dns if nombres and not sondear: @@ -721,7 +796,7 @@ def main(): censo = { "fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts, - "dominios": doms, "datos": dat, "contenido": cont, "montajes": montajes(t), + "dominios": doms, "datos": dat, "contenido": cont, "sitios": sitios, "montajes": montajes(t), "fuera_de_git": fuera_de_git(t), } resumen(censo) diff --git a/scripts/mudanza/formatos/caddy.py b/scripts/mudanza/formatos/caddy.py index f3e8347b..0ac100cc 100644 --- a/scripts/mudanza/formatos/caddy.py +++ b/scripts/mudanza/formatos/caddy.py @@ -1,7 +1,7 @@ -"""Plugin de formato: ESCRITOR de Caddy (SDD 29).""" -import os, sys +"""Plugin de formato: Caddy — ESCRITOR y LECTOR (SDD 29).""" +import os, re, sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from formatos import escritor # noqa: E402 +from formatos import escritor, lector # noqa: E402 def _direcciones(s): @@ -72,3 +72,131 @@ def escribir(cfg): if e["motivo"]: L.append(f"# └─ {e['motivo'][:96]}") return "\n".join(L) + "\n" + + +# ── LECTOR ────────────────────────────────────────────────────────────────────────────────────── +# Caddy se lee además de escribirse por dos motivos: mucha gente se muda DESDE Caddy, y —el que +# empujó a escribirlo— el CENSO necesita saber a qué directorio apunta cada sitio. Un bloque cuyo +# `root` no existe es un fósil que sirve 502, y eso se detecta leyendo el fichero: sin red, sin +# pedirle una página al servidor, sin arriesgar su fail2ban. + +_DIRECTIVAS_SITIO = {"root", "reverse_proxy", "file_server", "redir", "respond", "tls", "try_files", + "handle", "handle_path", "encode", "header", "log", "php_fastcgi", "rewrite", + "route", "import", "basicauth", "abort", "@"} + + +# Una dirección de sitio de Caddy: `ejemplo.com`, `:8080`, `http://x.com:81`, `*.ejemplo.com`. +_DIR = re.compile(r'^(?:(?:https?|h2c)://)?(?:\*\.)?[A-Za-z0-9_.-]+(?::\d+)?/?$|^:\d+$') + + +def _es_cabecera_de_sitio(ln): + """`ejemplo.com, www.ejemplo.com {` — una cabecera de sitio, no una directiva con bloque. + + ⚠ La distinción no es cosmética, y una lista negra de directivas NO alcanza: probado contra el + Caddyfile real, `log { output file /var/log/…` y el snippet `(acceso) {` pasaban por sitios, o + sea DOS dominios inventados que el censo habría puesto a decidir. La regla que sí aguanta es + positiva: **todos** los tokens de la cabecera tienen que PARECER una dirección.""" + if not ln.endswith("{") or ln.startswith(("}", "@", "#", "(")): + return False + cab = ln[:-1].strip() + if not cab: + return False + piezas = [x for x in cab.replace(",", " ").split() if x] + if not piezas or piezas[0].lstrip("@") in _DIRECTIVAS_SITIO: + return False + return all(_DIR.match(x) for x in piezas) + + +@lector("caddy", familia="web") +def leer(texto, nombre=""): + from modelo import Config, Sitio, Ruta, Accion + cfg = Config(formato_origen="caddy") + acta = cfg.acta + sitio, prof, anidado = None, 0, "" + for n, cruda in enumerate(texto.splitlines(), 1): + ln = cruda.strip() + if not ln or ln.startswith("#"): + continue + if sitio is None: + if _es_cabecera_de_sitio(ln): + sitio, prof = Sitio(origen=n), 1 + for d in ln[:-1].replace(",", " ").split(): + d = d.strip() + esquema = d.split("://")[0] if "://" in d else "" + host = d.split("://")[-1] + host, _, puerto = host.partition(":") + if puerto: + sitio.puertos.append(puerto) + elif esquema: + sitio.puertos.append("80" if esquema == "http" else "443") + if host: + sitio.nombres.append(host) + acta.anota(n, "traducido", ln) + elif ln.endswith("{"): + prof += 1 + elif ln == "}": + prof = max(0, prof - 1) + else: + # Opciones globales, `import`, snippets: no son un sitio y no se pierden en silencio. + acta.anota(n, "no-aplica", ln, "fuera de todo bloque de sitio") + continue + + if ln.endswith("{"): + prof += 1 + anidado = ln[:-1].strip() + acta.anota(n, "SIN-TRADUCIR", ln, + "bloque anidado (`handle`, `route`, matcher…): el pivote no lo modela. De su " + "interior sólo se leen `root` y `reverse_proxy`") + continue + if ln == "}": + prof -= 1 + if prof == 0: + cfg.sitios.append(sitio) + sitio = None + continue + if prof > 1: + # ⚠ El bloque no se traduce, pero lo que APUNTA A DISCO sí se lee. Medido: el `root` de + # `git.tawasuyu.net` vive dentro de un `handle`, así que saltar el bloque entero hacía + # que el censo diera `/var/www/git-tawasuyu` por huérfano estando servido — y ese aviso, + # aplicado, tira el sitio. Que el pivote no sepa MODELAR algo no es razón para no VERLO. + pa = ln.split() + if pa[0] == "root" and len(pa) >= 2: + r = Ruta(patron=anidado, clase="prefijo", origen=n) + r.acciones.append(Accion("archivos", pa[-1])) + sitio.rutas.append(r) + elif pa[0] == "reverse_proxy" and len(pa) >= 2: + r = Ruta(patron=anidado, clase="prefijo", origen=n) + r.acciones.append(Accion("proxy", pa[-1])) + sitio.rutas.append(r) + continue + + p = ln.split() + d = p[0] + if d == "root" and len(p) >= 2: + sitio.raiz = p[-1] + acta.anota(n, "traducido", ln) + elif d == "reverse_proxy" and len(p) >= 2: + sitio.acciones.append(Accion("proxy", p[-1])) + acta.anota(n, "traducido", ln) + elif d == "file_server": + sitio.acciones.append(Accion("archivos", sitio.raiz)) + acta.anota(n, "traducido", ln) + elif d == "redir" and len(p) >= 2: + sitio.acciones.append(Accion("redir", " ".join(p[1:]))) + acta.anota(n, "traducido", ln) + elif d == "tls" and len(p) >= 3: + sitio.tls = {"cert": p[1], "key": p[2]} + acta.anota(n, "traducido", ln) + elif d == "try_files": + sitio.acciones.append(Accion("intentar", " ".join(p[1:]))) + acta.anota(n, "traducido", ln) + elif d in ("encode", "log", "header", "tls"): + acta.anota(n, "no-aplica", ln, f"`{d}` es afinado del servidor, no de lo que sirve") + else: + acta.anota(n, "SIN-TRADUCIR", ln, f"directiva `{d}` no reconocida") + + if sitio is not None: + acta.anota(sitio.origen, "SIN-TRADUCIR", f"sitio {', '.join(sitio.nombres)}", + "el bloque no se cierra: falta una llave. Se descarta, porque un sitio a medias " + "traducido es peor que uno ausente") + return cfg diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py index 8cc3074e..b1dcb8ef 100755 --- a/scripts/mudanza/planear.py +++ b/scripts/mudanza/planear.py @@ -306,7 +306,7 @@ def origen_binario(e, repo=None): return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano") -def recomendar(tipo, e, datos_que_mueren=()): +def recomendar(tipo, e, datos_que_mueren=(), sitios=()): """Devuelve `(decision, motivo)`. **Recomendación, nunca decisión**: se imprime con su razón para que aceptarla sea barato y equivocarse sea caro de no notar. @@ -334,6 +334,15 @@ def recomendar(tipo, e, datos_que_mueren=()): return "", "" if tipo == "dominio": + # ⚠ Evidencia más fuerte que el DNS, y sin tocar la red: si el bloque de este dominio en la + # config del servidor apunta a un directorio que NO EXISTE, no hay nada que servir — devuelve + # 502 resuelva donde resuelva. Medido en gioser: `aura`, `sigma` y `kosmofono` apuntan a + # raíces borradas, y son justo los que en su día devolvían los 502 que hicieron que el censo + # se baneara a sí mismo al sondearlos por HTTP. Se lee del fichero; no hace falta preguntar. + for st in sitios: + if e.get("domain") in (st.get("nombres") or "").split() and st.get("raiz_existe") == "no": + return "muere", (f"su bloque en {st['config']} apunta a `{st['raiz']}`, que NO " + f"EXISTE: no hay nada que servir, devuelve 502 resuelva donde resuelva") if c == "vivo": return "muda", "resuelve a esta máquina y contesta" if c == "ya-mudado": @@ -363,7 +372,7 @@ def recomendar(tipo, e, datos_que_mueren=()): def sugerir(tipo, e, datos_que_mueren=()): - return recomendar(tipo, e, datos_que_mueren)[0] + return recomendar(tipo, e, datos_que_mueren, sitios)[0] def _etiqueta(tipo, nombre, e): @@ -411,7 +420,7 @@ def revisar(censo, interactivo=True): # ── 2. lo demás, con recomendación y motivo ───────────────────────────────────────────────── grupos = {"muda": [], "muere": [], "": []} for t, n, e in resto: - d, motivo = recomendar(t, e, mueren) + d, motivo = recomendar(t, e, mueren, censo.get("sitio", [])) e["_rec"], e["_motivo"] = d, motivo grupos[d].append((t, n, e)) @@ -666,12 +675,26 @@ def pasos(censo, target, key): doms_muertos = [n for t, n, _ in muere if t == "dominio"] det.append("#") det.append("# CONTENIDO EN LAS RAÍCES WEB — esto NO lo mueve ningún paso de este plan.") + # ⚠ Un directorio que la CONFIG referencia no es un huérfano, diga lo que diga su + # nombre. Sin esto el censo marcó `/var/www/git-tawasuyu` como «nadie lo recuerda» + # estando servido en la línea 174 del Caddyfile — y ese aviso, aplicado, tira el sitio. + servidos = {} + for st in censo.get("sitio", []): + if st.get("raiz"): + servidos.setdefault(st["raiz"].rstrip("/"), []).append(st.get("nombres", "")) for c in contenido: + ruta = c["path"].rstrip("/") quien = [d for d in doms_muertos if c in candidatos(d, [c])] - marca = ("podría ser de: " + ", ".join(quien[:4]) + - (f" (+{len(quien)-4})" if len(quien) > 4 else "")) if quien else \ - "no corresponde a NINGÚN dominio que muera: nadie lo recuerda, miralo" - det.append(f"# {c['path']:34} {c['size']:>6} ← {marca}") + if ruta in servidos: + marca = ("⚠ LO SIRVE la config para: " + + ", ".join(" ".join(servidos[ruta]).split()[:3]) + + " — no es huérfano; moverlo tira el sitio") + elif quien: + marca = ("podría ser de: " + ", ".join(quien[:4]) + + (f" (+{len(quien)-4})" if len(quien) > 4 else "")) + else: + marca = "no corresponde a NINGÚN dominio que muera ni lo sirve la config: miralo" + det.append(f"# {ruta:34} {c['size']:>6} ← {marca}") paso("muere", f"{len(muere)} cosa(s) NO se mudan y mueren con la máquina vieja", "# NO SE BORRA NADA ACÁ. Esta lista existe para leerse antes de apagar el origen:\n"