Files
takana/scripts/mudanza/formatos/caddy.py
T
SergioandClaude Opus 5 0f3b137578 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
2026-09-12 01:49:02 +00:00

203 lines
9.4 KiB
Python

"""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, lector # noqa: E402
def _direcciones(s):
"""En nginx dos `server` con el MISMO nombre se distinguen por su `listen`; en Caddy, por el
ESQUEMA de la dirección. Sin traducir eso, los bloques colisionan y caddy rechaza el fichero
entero con «ambiguous site definition» — encontrado validando con el binario real, no leyendo."""
solo_80 = bool(s.puertos) and all(p == "80" for p in s.puertos)
pref = "http://" if solo_80 else ""
return ", ".join(pref + n for n in s.nombres) or (":80" if solo_80 else ":443")
def _emitir_acciones(acciones, ind):
L = []
for a in acciones:
if a.tipo == "proxy":
L.append(f"{ind}reverse_proxy {a.valor.rstrip('/').replace('http://','').replace('https://','')}")
elif a.tipo == "archivos":
L.append(f"{ind}root * {a.valor}")
elif a.tipo == "intentar":
L.append(f"{ind}try_files {a.valor}")
elif a.tipo == "redir":
p = a.valor.split()
code = p[0] if p and p[0].isdigit() else ""
dest = " ".join(p[1:]) if code else a.valor
L.append(f"{ind}redir {dest} {code}".rstrip() if dest else f"{ind}respond {code}")
return L
@escritor("caddy", familia="web")
def escribir(cfg):
A = cfg.acta
L = [f"# Generado por scripts/mudanza/traducir.py desde una configuración de {cfg.formato_origen}.",
"# ⚠ NO es una traducción completa. Abajo va el ACTA de lo que NO se pudo traducir, con su",
"# línea de origen. Desplegar esto sin resolverlo es desplegar un servidor incompleto.",
""]
for s in cfg.sitios:
L.append(f"{_direcciones(s)} {{")
L.append(f"\t# desde la línea {s.origen} del {cfg.formato_origen} original")
if s.tls.get("cert"):
L.append(f"\ttls {s.tls.get('cert','')} {s.tls.get('key','')}")
if s.raiz:
L.append(f"\troot * {s.raiz}")
L += _emitir_acciones(s.acciones, "\t")
for r in s.rutas:
if r.clase == "regex":
# Caddy no tiene un equivalente directo de `location ~`. Se DECLARA en el acta en vez
# de emitir algo parecido: un `handle` de prefijo no es lo mismo que una regex, y
# parecerse es peor que faltar.
A.anota(r.origen, "SIN-TRADUCIR", f"ruta por regex: {r.patron}",
"Caddy no tiene equivalente directo de `location ~`; se escribe con "
"`@matcher path_regexp` y hay que revisar el orden de evaluación")
continue
cuerpo = _emitir_acciones(r.acciones, "\t\t")
if cuerpo:
L.append(f"\thandle {r.patron} {{ # línea {r.origen}")
L += cuerpo
L.append("\t}")
if s.raiz:
L.append("\tfile_server")
L.append("}")
L.append("")
pend = A.sin_traducir()
if pend:
L.append("# ══ ACTA: lo que NO se tradujo — hay que resolverlo a mano ══")
for e in pend:
L.append(f"# línea {e['linea']:>4} {e['directiva'][:70]}")
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