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
This commit is contained in:
Sergio
2026-09-12 01:49:02 +00:00
co-authored by Claude Opus 5
parent 30d6f5e9f3
commit 0f3b137578
4 changed files with 279 additions and 11 deletions
+42
View File
@@ -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
+76 -1
View File
@@ -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)
+131 -3
View File
@@ -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 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
+30 -7
View File
@@ -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"