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:
@@ -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
|
||||
|
||||
@@ -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',''))}")
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user