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
+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)