From f2bffd9e4ba6a15aa5e4b64c4130d119caed3645 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 12 Sep 2026 03:03:49 +0000 Subject: [PATCH] =?UTF-8?q?planear:=20cruzar=20la=20config=20contra=20las?= =?UTF-8?q?=20decisiones=20=E2=80=94=20y=20los=20puertos=20se=20adjudicaba?= =?UTF-8?q?n=20por=20NOMBRE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x --- docs/29-mudanza.md | 35 +++++++++++++++++ scripts/mudanza/censar.py | 32 +++++++++++++--- scripts/mudanza/formatos/caddy.py | 6 ++- scripts/mudanza/planear.py | 63 +++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md index 563cbd28..37b94b46 100644 --- a/docs/29-mudanza.md +++ b/docs/29-mudanza.md @@ -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 diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py index 4badc9cb..22ff3a32 100755 --- a/scripts/mudanza/censar.py +++ b/scripts/mudanza/censar.py @@ -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',''))}") diff --git a/scripts/mudanza/formatos/caddy.py b/scripts/mudanza/formatos/caddy.py index 0ac100cc..b2739113 100644 --- a/scripts/mudanza/formatos/caddy.py +++ b/scripts/mudanza/formatos/caddy.py @@ -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": diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py index 46980729..e278268a 100755 --- a/scripts/mudanza/planear.py +++ b/scripts/mudanza/planear.py @@ -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