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

La config del servidor declara de qué servicios depende: cada `reverse_proxy` y cada `php_fastcgi`
apuntan a algo. Cruzarlo contra la decisión tomada sobre cada servicio caza un fallo que ninguna otra
parte ve: **el dominio se MUDA y el servicio que lo sirve está marcado MUERE**. Las dos decisiones
son razonables por separado y juntas dejan el sitio nuevo devolviendo 502 — no lo ve el DNS, no lo
ven los procesos, y no lo ve quien decide de a una entrada por vez, que es como se decide.

En gioser apareció el revés: dos sitios proxean a un puerto que NINGÚN servicio censado sirve, o sea
que ya devuelven 502 hoy, en el origen — `mail.sigma.gioser.net` → :9000 y `api.gioser.net` → :8000.
Confirmado aparte con `ss -lntp`: no hay nada escuchando en ninguno de los dos.

**Y un falso positivo que hubo que matar primero.** La primera corrida acusaba a `sergio.gioser.net`
de mudarse dejando atrás a `shuma`. Falso, y la causa estaba en el CENSO: los puertos se adjudicaban
por prefijo de nombre (`proc.startswith(name[:15])`), así que un `shuma` DECLARADO-MUERTO se quedaba
con el 7378 — que lo escucha `shuma-gateway` (pid 294, verificado con `ss -lntp`). Un
declarado-muerto por definición no puede estar escuchando. Ahora se atan por PID, que es lo único sin
ambigüedad, con caída al nombre sólo para servicios VIVOS cuando `ss` no da el pid.

El puerto es la señal más fuerte de que algo sirve, así que colgárselo al servicio equivocado
envenena todo lo que se derive de él — acá se derivó un guardián acusando al inocente, que es la
forma más rápida de que un guardián se deje de leer.

De paso, el lector de Caddy entiende `php_fastcgi` (antes caía en «directiva no reconocida») y
registra a dónde proxea cada sitio, incluidos los bloques anidados.

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 03:03:49 +00:00
co-authored by Claude Opus 5
parent 53de4e259a
commit f2bffd9e4b
4 changed files with 130 additions and 6 deletions
+35
View File
@@ -301,6 +301,41 @@ 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 terdecies · El cruce que nadie hacía: un dominio que se muda sin su backend
La config del servidor **declara de qué servicios depende** — cada `reverse_proxy` y cada
`php_fastcgi` apuntan a algo. Cruzar eso contra la decisión tomada sobre cada servicio caza un fallo
que ninguna otra parte puede ver:
> el dominio se **muda** y el servicio que lo sirve está marcado **muere**.
Las dos decisiones son razonables por separado —el dominio se usa, el servicio parecía
prescindible— y juntas dejan el sitio nuevo devolviendo 502. No lo ve el DNS, no lo ven los procesos,
y no lo ve quien decide **de a una entrada por vez**, que es como se decide.
También caza el revés, y en gioser eso es lo que apareció: dos sitios proxean a un puerto que
**ningún servicio censado sirve**, o sea que ya devuelven 502 *hoy, en el origen*.
```
mail.sigma.gioser.net ↳ localhost:9000 nadie escucha ahí
api.gioser.net ↳ localhost:8000 nadie escucha ahí
```
Confirmado aparte con `ss -lntp`: efectivamente no hay nada en 8000 ni en 9000.
#### Y un falso positivo que hubo que matar primero
La primera corrida acusaba a `sergio.gioser.net` de mudarse dejando atrás a `shuma`. Era falso, y la
causa estaba **en el censo**: los puertos se adjudicaban por prefijo de nombre
(`proc.startswith(name[:15])`), así que un `shuma` **declarado-muerto** se quedaba con el 7378 — que
lo escucha `shuma-gateway`, comprobado con `ss -lntp`. Un declarado-muerto **por definición no puede
estar escuchando**.
Ahora los puertos se atan por **PID**, que es lo único sin ambigüedad. El puerto es la señal más
fuerte de que algo sirve, así que colgárselo al servicio equivocado envenena todo lo que se derive de
él — y aquí se derivó un guardián acusando al inocente, que es la forma más rápida de que un guardián
se deje de leer.
### 4.0 duodecies · La cuenta de lo que falta, bien hecha
`origen_binario` buscaba la receta por el **nombre del servicio**, y ése casi nunca es el nombre del
+27 -5
View File
@@ -271,13 +271,17 @@ def puertos(t):
for l in out.splitlines():
m = re.search(r'(\S+):(\d+)\s', l)
proc = re.search(r'\("([^"]+)"|/(\S+)\s*$', l)
if m:
res.append({"addr": m.group(1), "port": int(m.group(2)),
"proc": (proc.group(1) or proc.group(2)) if proc else "?"})
# El PID es lo que ata el puerto a un servicio sin ambigüedad. Ver comentario en la
# asignación: por NOMBRE, un `shuma` declarado-muerto se quedaba con el puerto del
# `shuma-gateway` que lo escucha de verdad.
pidm = re.search(r'pid=(\d+)', l)
res.append({"addr": m.group(1), "port": int(m.group(2)),
"proc": (proc.group(1) or proc.group(2)) if proc else "?",
"pid": int(pidm.group(1)) if pidm else 0}) if m else None
# dedup por (puerto, proc)
vistos, uniq = set(), []
for r in sorted(res, key=lambda x: x["port"]):
k = (r["port"], r["proc"])
k = (r["port"], r["proc"], r.get("pid", 0))
if k not in vistos:
vistos.add(k); uniq.append(r)
return uniq
@@ -357,9 +361,14 @@ def sitios_web(t):
# 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"]
# A DÓNDE PROXEA este sitio: la config declara de qué servicios depende, y eso es
# lo único que puede cazar «este dominio se muda y el servicio que lo sirve se mata».
prox = [a.valor for a in s.acciones if a.tipo == "proxy"] + [
a.valor for r in s.rutas for a in r.acciones if a.tipo == "proxy"]
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,
"proxies": " ".join(dict.fromkeys(prox)),
"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"]})
@@ -656,7 +665,20 @@ def emitir_toml(c):
L.append(f"class = {toml_escape(s['class'])}")
L.append(f"declared_in = {json.dumps(s['declared_in'])}")
L.append(f"pids = {json.dumps(s['pids'])}")
L.append(f"ports = {json.dumps([p['port'] for p in c['puertos'] if p['proc'].startswith(s['name'][:15])])}")
# ⚠ Por PID, no por nombre. Emparejar `proc.startswith(name[:15])` le daba el puerto 7378
# —que escucha `shuma-gateway`, comprobado con `ss -lntp`— al `shuma` DECLARADO-MUERTO, que
# por definición no puede estar escuchando nada. El puerto es la señal más fuerte de que algo
# sirve, así que colgárselo al servicio equivocado envenena todo lo que se derive de él.
_pids = set(s.get("pids") or [])
_mios = [p["port"] for p in c["puertos"] if p.get("pid") and p["pid"] in _pids]
if not _mios and _pids:
# Sin pid en la salida de `ss` (sin permisos, o `netstat` viejo) se cae al nombre, pero
# sólo para servicios VIVOS: un declarado-muerto nunca escucha.
_mios = [p["port"] for p in c["puertos"]
if not p.get("pid") and p["proc"].startswith(s["name"][:15])]
elif not _pids:
_mios = []
L.append(f"ports = {json.dumps(sorted(set(_mios)))}")
L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}")
L.append(f"cwd = {toml_escape(s.get('cwd',''))}")
L.append(f"exec = {toml_escape(s.get('exec',''))}")
+5 -1
View File
@@ -164,6 +164,10 @@ def leer(texto, nombre=""):
r = Ruta(patron=anidado, clase="prefijo", origen=n)
r.acciones.append(Accion("archivos", pa[-1]))
sitio.rutas.append(r)
elif pa[0] == "php_fastcgi" and len(pa) >= 2:
r = Ruta(patron=anidado, clase="prefijo", origen=n)
r.acciones.append(Accion("proxy", 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]))
@@ -175,7 +179,7 @@ def leer(texto, nombre=""):
if d == "root" and len(p) >= 2:
sitio.raiz = p[-1]
acta.anota(n, "traducido", ln)
elif d == "reverse_proxy" and len(p) >= 2:
elif d in ("reverse_proxy", "php_fastcgi") and len(p) >= 2:
sitio.acciones.append(Accion("proxy", p[-1]))
acta.anota(n, "traducido", ln)
elif d == "file_server":
+63
View File
@@ -567,6 +567,54 @@ def bloque_toml(texto):
return _TRIPLE_DOBLE + "\n" + escapado + "\n" + _TRIPLE_DOBLE
def incoherencias(censo):
"""Cruza lo que la config DECLARA NECESITAR contra lo que se decidió con cada servicio.
LO QUE ESTO CAZA Y NADA MÁS PODÍA
Un dominio que se muda y el servicio que lo sirve marcado `muere`. Las dos decisiones son
razonables por separado el dominio se usa, el servicio parecía prescindible y juntas dejan el
sitio nuevo devolviendo 502. No lo ve el DNS, no lo ve el censo de procesos y no lo ve quien
decide de a una entrada por vez, que es como se decide.
También caza el revés: un `reverse_proxy` a un puerto que NINGÚN servicio censado sirve. Eso ya
está roto HOY, en el origen, y mudarlo es mudar un 502."""
por_puerto, por_nombre = {}, {}
for s in censo.get("servicio", []):
por_nombre[s["name"].lower()] = s
for p in s.get("ports", []):
por_puerto[str(p)] = s
dec = {}
for d in censo.get("dominio", []):
dec[d["domain"]] = d.get("decision", "")
out = []
for st in censo.get("sitio", []):
doms = [d for d in (st.get("nombres") or "").split() if dec.get(d) == "muda"]
if not doms:
continue
for destino in (st.get("proxies") or "").split():
if destino.startswith("unix/"):
# `unix//run/php-fpm/php-fpm.sock` → el servicio se identifica por su nombre.
base = destino.rstrip("/").split("/")[-1].removesuffix(".sock")
svc = por_nombre.get(base) or por_nombre.get(base.split("-")[0])
clave = base
else:
clave = destino.rsplit(":", 1)[-1]
svc = por_puerto.get(clave)
if svc is None:
out.append((", ".join(doms), destino,
"NINGÚN servicio censado sirve ese destino: el sitio ya devuelve 502 "
"HOY, en el origen. Mudarlo es mudar un 502"))
elif svc.get("decision") == "muere":
out.append((", ".join(doms), f"{destino}{svc['name']}",
f"el dominio se MUDA y `{svc['name']}`, que es quien lo sirve, está "
f"marcado MUERE. El sitio nuevo va a devolver 502"))
elif not svc.get("decision"):
out.append((", ".join(doms), f"{destino}{svc['name']}",
f"el dominio se muda y `{svc['name']}`, que lo sirve, NO tiene decisión"))
return out
def pasos(censo, target, key):
ssh_opts = "-o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
if key:
@@ -587,6 +635,21 @@ def pasos(censo, target, key):
muere = [(t, n, e) for t, n, e in entradas(censo) if e.get("decision") == "muere"]
contenido = censo.get("contenido", [])
# ── 0 ter. COHERENCIA ───────────────────────────────────────────────────────────────────────
incoh = incoherencias(censo)
if incoh:
det = ["# Cada línea es un dominio que se MUDA cuyo backend no va a estar del otro lado.",
"# Las dos decisiones son razonables por separado; juntas dejan un 502.", "#"]
for dom, destino, motivo in incoh:
det.append(f"# {dom[:44]}")
det.append(f"# ↳ {destino}")
det.append(f"# {motivo}")
paso("coherencia", f"{len(incoh)} dominio(s) que se mudan SIN su backend", "\n".join(det),
verifica="revisión humana: o se muda el backend, o el dominio no se muda",
verifica_tipo="humano",
nota="Nadie más puede cazar esto: no lo ve el DNS, no lo ven los procesos, y no lo ve "
"quien decide de a una entrada por vez — que es como se decide.")
# ── 0 bis. RESCATE, antes que nada ──────────────────────────────────────────────────────────
# Va PRIMERO, antes del preflight, porque es el único paso del plan que puede volverse
# IMPOSIBLE mientras se piensa el resto: estos binarios ya no existen en disco y sólo viven en el