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…
planear: cruzar la config contra las decisiones — y los puertos se adjudicaban por NOMBRE
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