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 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ main scripts/mudanza/formatos/caddy.py Python · 206 siq'ikuna · 9.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

planear: cruzar la config contra las decisiones — y los puertos se adjudicaban por NOMBRE f2bffd9e4 · Sergio ·

1"""Plugin de formato: Caddy — ESCRITOR y LECTOR (SDD 29)."""
2import os, re, sys
3sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4from formatos import escritor, lector                   # noqa: E402
5
6
7def _direcciones(s):
8    """En nginx dos `server` con el MISMO nombre se distinguen por su `listen`; en Caddy, por el
9    ESQUEMA de la dirección. Sin traducir eso, los bloques colisionan y caddy rechaza el fichero
10    entero con «ambiguous site definition» — encontrado validando con el binario real, no leyendo."""
11    solo_80 = bool(s.puertos) and all(p == "80" for p in s.puertos)
12    pref = "http://" if solo_80 else ""
13    return ", ".join(pref + n for n in s.nombres) or (":80" if solo_80 else ":443")
14
15
16def _emitir_acciones(acciones, ind):
17    L = []
18    for a in acciones:
19        if a.tipo == "proxy":
20            L.append(f"{ind}reverse_proxy {a.valor.rstrip('/').replace('http://','').replace('https://','')}")
21        elif a.tipo == "archivos":
22            L.append(f"{ind}root * {a.valor}")
23        elif a.tipo == "intentar":
24            L.append(f"{ind}try_files {a.valor}")
25        elif a.tipo == "redir":
26            p = a.valor.split()
27            code = p[0] if p and p[0].isdigit() else ""
28            dest = " ".join(p[1:]) if code else a.valor
29            L.append(f"{ind}redir {dest} {code}".rstrip() if dest else f"{ind}respond {code}")
30    return L
31
32
33@escritor("caddy", familia="web")
34def escribir(cfg):
35    A = cfg.acta
36    L = [f"# Generado por scripts/mudanza/traducir.py desde una configuración de {cfg.formato_origen}.",
37         "# ⚠ NO es una traducción completa. Abajo va el ACTA de lo que NO se pudo traducir, con su",
38         "#   línea de origen. Desplegar esto sin resolverlo es desplegar un servidor incompleto.",
39         ""]
40    for s in cfg.sitios:
41        L.append(f"{_direcciones(s)} {{")
42        L.append(f"\t# desde la línea {s.origen} del {cfg.formato_origen} original")
43        if s.tls.get("cert"):
44            L.append(f"\ttls {s.tls.get('cert','')} {s.tls.get('key','')}")
45        if s.raiz:
46            L.append(f"\troot * {s.raiz}")
47        L += _emitir_acciones(s.acciones, "\t")
48        for r in s.rutas:
49            if r.clase == "regex":
50                # Caddy no tiene un equivalente directo de `location ~`. Se DECLARA en el acta en vez
51                # de emitir algo parecido: un `handle` de prefijo no es lo mismo que una regex, y
52                # parecerse es peor que faltar.
53                A.anota(r.origen, "SIN-TRADUCIR", f"ruta por regex: {r.patron}",
54                        "Caddy no tiene equivalente directo de `location ~`; se escribe con "
55                        "`@matcher path_regexp` y hay que revisar el orden de evaluación")
56                continue
57            cuerpo = _emitir_acciones(r.acciones, "\t\t")
58            if cuerpo:
59                L.append(f"\thandle {r.patron} {{    # línea {r.origen}")
60                L += cuerpo
61                L.append("\t}")
62        if s.raiz:
63            L.append("\tfile_server")
64        L.append("}")
65        L.append("")
66
67    pend = A.sin_traducir()
68    if pend:
69        L.append("# ══ ACTA: lo que NO se tradujo — hay que resolverlo a mano ══")
70        for e in pend:
71            L.append(f"#   línea {e['linea']:>4}  {e['directiva'][:70]}")
72            if e["motivo"]:
73                L.append(f"#             └─ {e['motivo'][:96]}")
74    return "\n".join(L) + "\n"
75
76
77# ── LECTOR ──────────────────────────────────────────────────────────────────────────────────────
78# Caddy se lee además de escribirse por dos motivos: mucha gente se muda DESDE Caddy, y —el que
79# empujó a escribirlo— el CENSO necesita saber a qué directorio apunta cada sitio. Un bloque cuyo
80# `root` no existe es un fósil que sirve 502, y eso se detecta leyendo el fichero: sin red, sin
81# pedirle una página al servidor, sin arriesgar su fail2ban.
82
83_DIRECTIVAS_SITIO = {"root", "reverse_proxy", "file_server", "redir", "respond", "tls", "try_files",
84                     "handle", "handle_path", "encode", "header", "log", "php_fastcgi", "rewrite",
85                     "route", "import", "basicauth", "abort", "@"}
86
87
88# Una dirección de sitio de Caddy: `ejemplo.com`, `:8080`, `http://x.com:81`, `*.ejemplo.com`.
89_DIR = re.compile(r'^(?:(?:https?|h2c)://)?(?:\*\.)?[A-Za-z0-9_.-]+(?::\d+)?/?$|^:\d+$')
90
91
92def _es_cabecera_de_sitio(ln):
93    """`ejemplo.com, www.ejemplo.com {` — una cabecera de sitio, no una directiva con bloque.
94
95    ⚠ La distinción no es cosmética, y una lista negra de directivas NO alcanza: probado contra el
96    Caddyfile real, `log { output file /var/log/…` y el snippet `(acceso) {` pasaban por sitios, o
97    sea DOS dominios inventados que el censo habría puesto a decidir. La regla que sí aguanta es
98    positiva: **todos** los tokens de la cabecera tienen que PARECER una dirección."""
99    if not ln.endswith("{") or ln.startswith(("}", "@", "#", "(")):
100        return False
101    cab = ln[:-1].strip()
102    if not cab:
103        return False
104    piezas = [x for x in cab.replace(",", " ").split() if x]
105    if not piezas or piezas[0].lstrip("@") in _DIRECTIVAS_SITIO:
106        return False
107    return all(_DIR.match(x) for x in piezas)
108
109
110@lector("caddy", familia="web")
111def leer(texto, nombre=""):
112    from modelo import Config, Sitio, Ruta, Accion
113    cfg = Config(formato_origen="caddy")
114    acta = cfg.acta
115    sitio, prof, anidado = None, 0, ""
116    for n, cruda in enumerate(texto.splitlines(), 1):
117        ln = cruda.strip()
118        if not ln or ln.startswith("#"):
119            continue
120        if sitio is None:
121            if _es_cabecera_de_sitio(ln):
122                sitio, prof = Sitio(origen=n), 1
123                for d in ln[:-1].replace(",", " ").split():
124                    d = d.strip()
125                    esquema = d.split("://")[0] if "://" in d else ""
126                    host = d.split("://")[-1]
127                    host, _, puerto = host.partition(":")
128                    if puerto:
129                        sitio.puertos.append(puerto)
130                    elif esquema:
131                        sitio.puertos.append("80" if esquema == "http" else "443")
132                    if host:
133                        sitio.nombres.append(host)
134                acta.anota(n, "traducido", ln)
135            elif ln.endswith("{"):
136                prof += 1
137            elif ln == "}":
138                prof = max(0, prof - 1)
139            else:
140                # Opciones globales, `import`, snippets: no son un sitio y no se pierden en silencio.
141                acta.anota(n, "no-aplica", ln, "fuera de todo bloque de sitio")
142            continue
143
144        if ln.endswith("{"):
145            prof += 1
146            anidado = ln[:-1].strip()
147            acta.anota(n, "SIN-TRADUCIR", ln,
148                       "bloque anidado (`handle`, `route`, matcher…): el pivote no lo modela. De su "
149                       "interior sólo se leen `root` y `reverse_proxy`")
150            continue
151        if ln == "}":
152            prof -= 1
153            if prof == 0:
154                cfg.sitios.append(sitio)
155                sitio = None
156            continue
157        if prof > 1:
158            # ⚠ El bloque no se traduce, pero lo que APUNTA A DISCO sí se lee. Medido: el `root` de
159            # `git.tawasuyu.net` vive dentro de un `handle`, así que saltar el bloque entero hacía
160            # que el censo diera `/var/www/git-tawasuyu` por huérfano estando servido — y ese aviso,
161            # aplicado, tira el sitio. Que el pivote no sepa MODELAR algo no es razón para no VERLO.
162            pa = ln.split()
163            if pa[0] == "root" and len(pa) >= 2:
164                r = Ruta(patron=anidado, clase="prefijo", origen=n)
165                r.acciones.append(Accion("archivos", pa[-1]))
166                sitio.rutas.append(r)
167            elif pa[0] == "php_fastcgi" and len(pa) >= 2:
168                r = Ruta(patron=anidado, clase="prefijo", origen=n)
169                r.acciones.append(Accion("proxy", pa[-1]))
170                sitio.rutas.append(r)
171            elif pa[0] == "reverse_proxy" and len(pa) >= 2:
172                r = Ruta(patron=anidado, clase="prefijo", origen=n)
173                r.acciones.append(Accion("proxy", pa[-1]))
174                sitio.rutas.append(r)
175            continue
176
177        p = ln.split()
178        d = p[0]
179        if d == "root" and len(p) >= 2:
180            sitio.raiz = p[-1]
181            acta.anota(n, "traducido", ln)
182        elif d in ("reverse_proxy", "php_fastcgi") and len(p) >= 2:
183            sitio.acciones.append(Accion("proxy", p[-1]))
184            acta.anota(n, "traducido", ln)
185        elif d == "file_server":
186            sitio.acciones.append(Accion("archivos", sitio.raiz))
187            acta.anota(n, "traducido", ln)
188        elif d == "redir" and len(p) >= 2:
189            sitio.acciones.append(Accion("redir", " ".join(p[1:])))
190            acta.anota(n, "traducido", ln)
191        elif d == "tls" and len(p) >= 3:
192            sitio.tls = {"cert": p[1], "key": p[2]}
193            acta.anota(n, "traducido", ln)
194        elif d == "try_files":
195            sitio.acciones.append(Accion("intentar", " ".join(p[1:])))
196            acta.anota(n, "traducido", ln)
197        elif d in ("encode", "log", "header", "tls"):
198            acta.anota(n, "no-aplica", ln, f"`{d}` es afinado del servidor, no de lo que sirve")
199        else:
200            acta.anota(n, "SIN-TRADUCIR", ln, f"directiva `{d}` no reconocida")
201
202    if sitio is not None:
203        acta.anota(sitio.origen, "SIN-TRADUCIR", f"sitio {', '.join(sitio.nombres)}",
204                   "el bloque no se cierra: falta una llave. Se descarta, porque un sitio a medias "
205                   "traducido es peor que uno ausente")
206    return cfg