tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1488Shell 182Rust 107Markdown 963619 Definiciones

censar: leer la config del servidor web — cinco sitios sirven un `root` que NO EXISTE

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x

0f3b1375789a1487c8437037524f49d7fddb5c19 · por Sergio · (hace 1 mes) · padre 30d6f5e9f · ver el árbol en este commit

Delta semántico · 9

scripts/mudanza/censar.py

scripts/mudanza/formatos/caddy.py

scripts/mudanza/planear.py

4 archivos · +279 −11

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"