El censo ya tenía tres estados a propósito (`ausente` / `sin_permiso` / `ok`), pero el tercero sólo se detectaba sobre la PROPIA ruta: cuando el que no deja pasar es un ANCESTRO, `[ -e "$p" ]` contesta que no existe y la ruta caía en `ausente`, que el censo descarta sin decir nada. Medido en gioser: `/root/.local` es 0700 ⇒ un censo corrido como `sergio` daba `/root/.local/bin` por AUSENTE. Como root son 572 M de binarios puestos a mano que ningún paquete provee — justo la clase que la mudanza no puede reconstruir. Arreglado: si un ancestro existe y no es atravesable, el estado es `sin_permiso`. Probado en los dos sentidos, que es lo que separa un guardián de un adorno: ancestro 0700 de otro dueño → sin_permiso (sale en el ⚠ del resumen) ruta que NO existe de verdad → ausente (no se reporta) ← el control que tiene que pasar Con el arreglo, el censo sin privilegio pasa de 2 rutas ciegas a 13, entre ellas el home entero de `artix`, que antes no figuraba de ninguna manera. Y el §6.34 con el estado MEDIDO de la mudanza hoy: de los 29 dominios queda UNO sirviéndose desde gioser (`sergio.gioser.net`); los otros seis vivos ya contestan 200 contra 2.29.29.217. El rescate del §6.20 sigue coincidiendo bit a bit con cuatro de los seis procesos que corren desde un inodo borrado, y los otros dos tienen en disco una versión más nueva. Lo que queda abierto son los pasos 6 y 7: el montón de daemons propios (shuma, willay, pacha, tejido, tupu, matilda…) y los datos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1053 lines
54 KiB
Python
Executable File
1053 lines
54 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""censar.py — CENSO de una máquina: qué corre, qué sirve, qué datos tiene. Sin IA, sin adivinar.
|
||
|
||
⚠ «Sólo lee» hay que decirlo con cuidado: NO ESCRIBE nada, pero las sondas de RED dejan huella. Ver
|
||
`sondear_dominio` — censar gioser contra sí mismo hizo que su propio fail2ban lo baneara.
|
||
|
||
Primer paso de la mudanza (SDD 29). Produce un inventario TOML que un humano revisa y que las etapas
|
||
siguientes consumen. NO toca nada: todo lo que hace son lecturas.
|
||
|
||
── LA REGLA QUE LO ORDENA TODO ─────────────────────────────────────────────────────────────────
|
||
Cruzar SIEMPRE lo DECLARADO contra lo VIVO, y reportar las tres clases por separado:
|
||
|
||
vivo declarado Y corriendo ⇒ candidato a mudarse
|
||
declarado-muerto declarado y sin proceso ⇒ candidato a morir
|
||
no-declarado corriendo y sin declaración ⇒ EL TRABAJO REAL: nadie sabe cómo levantarlo
|
||
|
||
No es teoría. Medido sobre gioser (2026-09-11):
|
||
· `rc-status` decía `stopped` de caddy, gitea, sshd, cronie y act-runner, y los CINCO estaban
|
||
vivos colgando de PID 1. Un censo que lea sólo el init habría producido un plan que no arranca.
|
||
· De los 19 dominios del Caddyfile, 8 no tenían DNS, 2 daban 502 y 3 ya vivían en OTRA máquina.
|
||
Sólo 6 eran reales. Un censo que lea sólo la config habría mudado 13 fósiles.
|
||
· Y el propio caddy de la caja NUEVA murió en un reinicio sin que nadie lo notara, porque estaba
|
||
arrancado a mano y no declarado. Esa es la clase `no-declarado`, y es la que duele.
|
||
|
||
── POR QUÉ CADA SONDA ──────────────────────────────────────────────────────────────────────────
|
||
`ppid==1` es la verdad de qué corre, no el init. Los sockets en LISTEN dicen qué SIRVE. El DNS y un
|
||
GET dicen si alguien lo usa. `du` dice qué pesa. Nada de esto necesita juicio: son hechos, y el
|
||
juicio (qué se muda, qué muere) lo pone el usuario en la etapa siguiente, sobre este fichero.
|
||
|
||
Uso:
|
||
scripts/mudanza/censar.py --host root@1.2.3.4 [--key ~/.ssh/id] [--out censo.toml]
|
||
scripts/mudanza/censar.py --local # censa esta máquina
|
||
|
||
La superficie va en inglés (CLAUDE.md regla 4); los mensajes, en castellano.
|
||
"""
|
||
import argparse, json, os, re, shlex, subprocess, sys, time
|
||
|
||
# ── ejecución de sondas ─────────────────────────────────────────────────────────────────────────
|
||
|
||
class Objetivo:
|
||
"""Una máquina a censar. `run` devuelve (salida, ok) y NUNCA levanta: una sonda que falla es un
|
||
dato («esa máquina no tiene ss»), no un motivo para abortar el censo entero."""
|
||
|
||
def __init__(self, host=None, key=None, timeout=25):
|
||
self.host, self.key, self.timeout = host, key, timeout
|
||
|
||
def run(self, cmd):
|
||
if self.host:
|
||
base = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||
"-o", f"ConnectTimeout={self.timeout}", "-o", "BatchMode=yes"]
|
||
if self.key:
|
||
base += ["-i", os.path.expanduser(self.key)]
|
||
argv = base + [self.host, cmd]
|
||
else:
|
||
argv = ["sh", "-c", cmd]
|
||
try:
|
||
p = subprocess.run(argv, capture_output=True, text=True, timeout=self.timeout * 4)
|
||
return p.stdout.strip(), p.returncode == 0
|
||
except Exception as e:
|
||
return f"(sonda falló: {e})", False
|
||
|
||
|
||
# ── sondas ──────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
def identidad(t):
|
||
"""Quién es esta máquina. El hostname MIENTE (gioser dice `momento`): la IP es el discriminador."""
|
||
out, _ = t.run("hostname; ip -4 addr show scope global 2>/dev/null | grep -o 'inet [0-9.]*' | "
|
||
"cut -d' ' -f2 | head -3; uname -r; cat /proc/1/comm")
|
||
ls = [l for l in out.splitlines() if l.strip()]
|
||
return {
|
||
"hostname": ls[0] if ls else "?",
|
||
"ips": [l for l in ls[1:-2]] if len(ls) > 3 else [],
|
||
"kernel": ls[-2] if len(ls) >= 2 else "?",
|
||
"pid1": ls[-1] if ls else "?",
|
||
}
|
||
|
||
|
||
def invocaciones(t, pids):
|
||
"""La LÍNEA DE COMANDO y el `cwd` de cada servicio vivo, leídos de `/proc`.
|
||
|
||
Es el dato que convierte `no-declarado` de «acá hay un problema» en «acá está la solución»: si
|
||
nadie sabe cómo levantar un servicio, lo que hace falta para declararlo es exactamente cómo se
|
||
está ejecutando ahora. Sin esto el plan sólo puede decir «declaralo», que es lo que uno ya sabía.
|
||
|
||
⚠ Se lee `cmdline` y `cwd`, NO el entorno: `/proc/<pid>/environ` suele traer tokens y
|
||
contraseñas, y un censo que se guarda en un fichero y se manda por ahí no puede llevarlos."""
|
||
out = {}
|
||
for nombre, ps in pids.items():
|
||
pid = ps[0]
|
||
# ⚠ SONDAS SEPARADAS, y es un bug pagado: iban en un solo comando, y como `readlink
|
||
# /proc/<pid>/cwd` necesita permiso de ptrace (dueño o root), en cualquier proceso de root
|
||
# el comando entero salía ≠0 y se descartaba TAMBIÉN el `cmdline`, que sí se podía leer.
|
||
# Resultado: 26 de 37 servicios sin invocación, justo el dato que da sentido al censo.
|
||
# Una sonda que falla es un dato; no puede arrastrarse a las que funcionaron.
|
||
cmd, _ = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null")
|
||
cwd, _ = t.run(f"readlink /proc/{pid}/cwd 2>/dev/null")
|
||
# ⚠ Tercera sonda, y también separada. Muchos demonios REESCRIBEN su `argv[0]` para decir en
|
||
# qué andan (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la
|
||
# línea de comando NO dice cuál es el binario. `/proc/<pid>/exe` sí — pero necesita permiso
|
||
# de ptrace, o sea ser el dueño o root. Medido en gioser con uid 1001: 7 de 38 servicios
|
||
# quedaban en «no se pudo leer su binario» por esto, y no por ser realmente desconocidos.
|
||
exe, _ = t.run(f"readlink /proc/{pid}/exe 2>/dev/null")
|
||
cmd, cwd, exe = cmd.strip(), cwd.strip(), exe.strip()
|
||
# ⚠⚠ `/proc/<pid>/exe` termina en « (deleted)» cuando el fichero YA NO ESTÁ en disco: el
|
||
# proceso sostiene el inodo y nadie más lo puede abrir por su ruta. Medido en gioser: CUATRO
|
||
# servicios están así, y en dos de ellos hay AHORA otro fichero en la misma ruta, de distinto
|
||
# tamaño — `tejido` corre 12 750 368 bytes y en su ruta hay 13 815 864. Copiar la ruta muda
|
||
# OTRA COSA, en silencio, y el servicio nuevo no es el que estaba andando.
|
||
#
|
||
# Se recupera leyendo `/proc/<pid>/exe`, y SÓLO mientras el proceso viva: al reiniciar gioser
|
||
# —o al morirse ese pid— ese binario desaparece para siempre. En una mudanza que termina
|
||
# BORRANDO el origen, esto es lo más urgente del censo.
|
||
borrado = exe.endswith(" (deleted)")
|
||
if borrado:
|
||
exe = exe[:-len(" (deleted)")]
|
||
vivo_b = disco_b = ""
|
||
if borrado:
|
||
v, _ = t.run(f"stat -L -c %s /proc/{pid}/exe 2>/dev/null")
|
||
d, _ = t.run(f"stat -c %s {shlex.quote(exe)} 2>/dev/null")
|
||
vivo_b = v.strip() if v.strip().isdigit() else ""
|
||
disco_b = d.strip() if d.strip().isdigit() else ""
|
||
for v in ("cmd", "cwd", "exe"):
|
||
if locals()[v].startswith("(sonda falló"):
|
||
cmd, cwd, exe = (("" if v == "cmd" else cmd), ("" if v == "cwd" else cwd),
|
||
("" if v == "exe" else exe))
|
||
sup = SUPERVISADOS.get(pid)
|
||
if sup:
|
||
cmd, exe = sup["cmdline"], sup["exec"]
|
||
if cmd or cwd or exe:
|
||
out[nombre] = {"cmdline": cmd, "cwd": cwd,
|
||
"exe": exe if exe.startswith("/") else "",
|
||
"usuario": (sup or {}).get("usuario", ""),
|
||
"exe_borrado": borrado, "pid": pid,
|
||
"bytes_vivo": vivo_b, "bytes_disco": disco_b}
|
||
return out
|
||
|
||
|
||
def duenos(t, ejecutables):
|
||
"""¿Quién PROVEE el binario de cada servicio? Determinista, preguntándole al gestor de paquetes
|
||
del origen.
|
||
|
||
Es lo que convierte «instalá el paquete» —que es lo que uno ya sabía— en una instrucción: o el
|
||
binario lo trae un paquete con nombre, o NO lo trae nadie y entonces es un compilado suelto que
|
||
hay que llevar o reconstruir. La diferencia decide el trabajo, y no se adivina.
|
||
|
||
Una consulta por lote, no una por servicio: son decenas de servicios y cada `ssh` cuesta."""
|
||
if not ejecutables:
|
||
return {}
|
||
lista = " ".join(shlex.quote(e) for e in sorted(set(ejecutables)))
|
||
# Se prueba el gestor que exista. `2>/dev/null || echo -` ⇒ un fichero sin dueño no aborta el lote.
|
||
guion = (
|
||
'for f in %s; do '
|
||
' if command -v pacman >/dev/null 2>&1; then o=$(pacman -Qoq "$f" 2>/dev/null); '
|
||
' elif command -v dpkg >/dev/null 2>&1; then o=$(dpkg -S "$f" 2>/dev/null | cut -d: -f1); '
|
||
' elif command -v apk >/dev/null 2>&1; then o=$(apk info -W "$f" 2>/dev/null | tail -1 '
|
||
' | sed "s/.* is owned by //;s/-[0-9].*//"); '
|
||
' elif command -v rpm >/dev/null 2>&1; then o=$(rpm -qf "$f" 2>/dev/null); '
|
||
' else o=""; fi; '
|
||
' echo "$f|${o:-}"; done'
|
||
) % lista
|
||
out, _ = t.run(guion)
|
||
res = {}
|
||
for l in out.splitlines():
|
||
if "|" in l:
|
||
f, o = l.split("|", 1)
|
||
res[f.strip()] = o.strip()
|
||
return res
|
||
|
||
|
||
# Envoltorios que NO son el servicio: el nombre de verdad está en sus argumentos. Sin desenvolverlos,
|
||
# los CINCO `supervise-daemon` de gioser se juntaban en UNA entrada llamada `supervise-daemo` — cinco
|
||
# servicios distintos (shuma-daemon, dbus, metalog…) colapsados en una tarjeta con el nombre del
|
||
# envoltorio. Un servicio de más es ruido; cinco servicios fundidos en uno es un plan que no arranca.
|
||
SUPERVISORES = {"supervise-daemon", "start-stop-daemon", "s6-supervise", "runsv", "daemonize"}
|
||
_COMM_SUP = {s[:15] for s in SUPERVISORES}
|
||
|
||
# pid → lo que el supervisor manda de verdad. Se llena al listar los procesos y se consulta al leer
|
||
# `/proc`: el pid con ppid==1 es el SUPERVISOR, así que su `cmdline` y su `exe` describen al
|
||
# envoltorio, no al servicio.
|
||
SUPERVISADOS = {}
|
||
|
||
|
||
def _mandado_por_supervisor(args):
|
||
"""El comando REAL que hay detrás de `supervise-daemon`, y con qué usuario corre.
|
||
|
||
El formato es `supervise-daemon <servicio> --start [opciones] <ejecutable> -- [argumentos]`, así
|
||
que el ejecutable es el ÚLTIMO token antes del `--` suelto. Comprobado contra los cinco de gioser
|
||
(dbus, dhcpcd, metalog, squid, shuma-daemon).
|
||
|
||
⚠ Sin esto, el censo guardaba como `cmdline` la línea del SUPERVISOR — y una tarjeta de arje
|
||
hecha con eso arrancaría `supervise-daemon` DENTRO de arje, que ya supervisa. Y el `exec` salía
|
||
`/usr/bin/supervise-daemon`, o sea que `squid` figuraba como provisto por el paquete `openrc`.
|
||
|
||
⚠⚠ Y se rescata el `--user`: `shuma-daemon` corre como `sergio`. Perder ese dato al desenvolver
|
||
sería la misma elisión que el escritor de arje reporta como SIN-TRADUCIR — un servicio que en el
|
||
origen corría sin privilegios terminaría de root en el destino, y nadie se enteraría."""
|
||
p = args.split()
|
||
if "--start" not in p:
|
||
return "", "", ""
|
||
resto = p[p.index("--start") + 1:]
|
||
usuario = ""
|
||
for i, x in enumerate(resto):
|
||
if x in ("--user", "-u") and i + 1 < len(resto):
|
||
usuario = resto[i + 1]
|
||
break
|
||
corte = resto.index("--") if "--" in resto else len(resto)
|
||
izq, der = resto[:corte], resto[corte + 1:]
|
||
if not izq or not izq[-1].startswith("/"):
|
||
return "", "", usuario
|
||
return izq[-1], " ".join([izq[-1]] + der), usuario
|
||
|
||
|
||
def _desenvolver(comm, args):
|
||
"""(nombre_real, args_reales) para un proceso que puede ser un envoltorio."""
|
||
p = args.split()
|
||
if not p:
|
||
return comm, args
|
||
base = os.path.basename(p[0])
|
||
if base not in SUPERVISORES and comm not in {s[:15] for s in SUPERVISORES}:
|
||
return comm, args
|
||
if base in ("supervise-daemon", "s6-supervise", "runsv") and len(p) > 1:
|
||
# `supervise-daemon <servicio> --start …` · `runsv /etc/service/<servicio>`
|
||
return os.path.basename(p[1].rstrip("/")), args
|
||
for i, x in enumerate(p): # start-stop-daemon
|
||
if x in ("--name", "-n") and i + 1 < len(p):
|
||
return p[i + 1], args
|
||
if x in ("--exec", "-x", "--startas", "-a") and i + 1 < len(p):
|
||
return os.path.basename(p[i + 1]), args
|
||
return comm, args
|
||
|
||
|
||
def _destruncar(comm, args):
|
||
"""`comm` viene del kernel y está capado a 15 caracteres: `willay-crosscheck` llega como
|
||
`willay-crossche`. Ese nombre truncado no es cosmético — es el que el plan mete en
|
||
`--in /etc/init.d/<nombre>` (ruta que no existe) y el que va de `label` en la tarjeta de arje.
|
||
La línea de comando sí trae el nombre entero, así que se usa ESA cuando confirma al truncado."""
|
||
if len(comm) < 15:
|
||
return comm
|
||
p = args.split()
|
||
if p:
|
||
base = os.path.basename(p[0])
|
||
if base.startswith(comm):
|
||
return base
|
||
return comm
|
||
|
||
|
||
def procesos_vivos(t):
|
||
"""Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración."""
|
||
out, _ = t.run("ps -eo pid=,ppid=,comm=,args= 2>/dev/null | awk '$2==1 && $1!=1'")
|
||
vivos = {}
|
||
for l in out.splitlines():
|
||
parts = l.split(None, 3)
|
||
if len(parts) < 3 or not parts[0].isdigit():
|
||
continue
|
||
pid, comm, args = int(parts[0]), parts[2], (parts[3] if len(parts) > 3 else "")
|
||
nombre, args = _desenvolver(comm, args)
|
||
nombre = _destruncar(nombre, args)
|
||
exe, linea, usuario = _mandado_por_supervisor(args) if comm[:15] in _COMM_SUP else ("", "", "")
|
||
if exe:
|
||
SUPERVISADOS[pid] = {"exec": exe, "cmdline": linea, "usuario": usuario}
|
||
vivos.setdefault(nombre, []).append(pid)
|
||
# los kernel threads no son servicios
|
||
return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq",
|
||
"migration", "rcu_", "irq/", "idle_"))}
|
||
|
||
|
||
def puertos(t):
|
||
"""Qué SIRVE esta máquina, y quién. Un servicio sin puerto no se muda igual que uno con puerto."""
|
||
out, ok = t.run("ss -lntpH 2>/dev/null || netstat -lntp 2>/dev/null | tail -n +3")
|
||
res = []
|
||
for l in out.splitlines():
|
||
m = re.search(r'(\S+):(\d+)\s', l)
|
||
proc = re.search(r'\("([^"]+)"|/(\S+)\s*$', l)
|
||
# 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"], r.get("pid", 0))
|
||
if k not in vistos:
|
||
vistos.add(k); uniq.append(r)
|
||
return uniq
|
||
|
||
|
||
def declarados(t):
|
||
"""Lo que el init DICE que hay. Se compara contra lo vivo — no se cree."""
|
||
fuentes = {}
|
||
# `rc-status -a` lista TODO lo disponible en el sistema, no lo que esta máquina usa: en gioser
|
||
# daba 100 «declarado-muerto» que no significan nada. `rc-update show` da lo ENCENDIDO por
|
||
# runlevel, que es lo que de verdad se declara.
|
||
out, ok = t.run("rc-update show 2>/dev/null | awk '{print $1}' | grep -E '^[a-z]'")
|
||
if ok and out:
|
||
fuentes["openrc"] = out.split()
|
||
out, ok = t.run("systemctl list-units --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}'")
|
||
if ok and out:
|
||
fuentes["systemd"] = [s.replace(".service", "") for s in out.split()]
|
||
out, ok = t.run("ls /etc/arje/cards.d/ 2>/dev/null")
|
||
if ok and out:
|
||
fuentes["arje-cards"] = [s.replace(".json", "") for s in out.split()]
|
||
out, ok = t.run("python3 -c \"import json;print(' '.join(n['label'] for n in "
|
||
"json.load(open('/ente/seed.card.json'))['genesis']))\" 2>/dev/null")
|
||
if ok and out:
|
||
fuentes["arje-seed"] = out.split()
|
||
out, ok = t.run("ls /etc/service /run/runit/service 2>/dev/null")
|
||
if ok and out:
|
||
fuentes["runit"] = out.split()
|
||
return fuentes
|
||
|
||
|
||
# Config del servidor web → qué sitio apunta a qué directorio. Se lee con los LECTORES del centro de
|
||
# traducción (`formatos/`), no con un parser propio: ya existen para caddy, nginx y apache, y tener
|
||
# dos parsers del mismo formato es cómo se separan sin que nadie lo note.
|
||
_CONFS = [("/etc/caddy/Caddyfile", "caddy"),
|
||
("/etc/nginx/nginx.conf", "nginx"),
|
||
("/etc/nginx/sites-enabled/*", "nginx"),
|
||
("/etc/apache2/sites-enabled/*", "apache"),
|
||
("/etc/httpd/conf.d/*", "apache")]
|
||
|
||
|
||
def repos_git(t, raices, ips, dominios):
|
||
"""Repositorios git bajo los datos, y si su remoto vive EN ESTA MÁQUINA.
|
||
|
||
── LA DEPENDENCIA CIRCULAR QUE MATA LA MUDANZA AL FINAL ─────────────────────────────────────
|
||
Medido en gioser: **25 repositorios cuyo único remoto es el gitea de la propia máquina** — entre
|
||
ellos `tawasuyu`, que produce 10 de los 13 binarios que nadie más provee. Apagar el origen no
|
||
borra sólo unos servicios: borra **el código con el que se volverían a construir**, y los clones
|
||
de trabajo están en el mismo disco que también muere.
|
||
|
||
Es un fallo que no se ve desde ninguna otra parte del censo —el repo no es un proceso, ni un
|
||
puerto, ni un dominio— y que sólo se descubre cuando ya no hay de dónde sacarlo.
|
||
|
||
Un remoto que apunta AFUERA es la prueba de que el código sobrevive. Por eso lo que se reporta no
|
||
es «tiene remoto» sino **si alguno de sus remotos NO es esta máquina**."""
|
||
if not raices:
|
||
return []
|
||
busca = " ".join(shlex.quote(r) for r in raices)
|
||
out, ok = t.run(f"find {busca} -maxdepth 4 -name .git -prune 2>/dev/null | head -300")
|
||
if not ok or not out.strip():
|
||
return []
|
||
mios = set(dominios) | set(ips) | {"localhost", "127.0.0.1"}
|
||
res = []
|
||
for g in out.split():
|
||
r = g[:-5] if g.endswith("/.git") else g
|
||
urls, ok2 = t.run(f"git -C {shlex.quote(r)} config --get-regexp "
|
||
f"'remote\\..*\\.(url|pushurl)' 2>/dev/null")
|
||
if not ok2 or not urls.strip() or urls.startswith("(sonda falló"):
|
||
continue
|
||
dentro, fuera = [], []
|
||
for l in urls.splitlines():
|
||
p = l.split(None, 1)
|
||
if len(p) != 2:
|
||
continue
|
||
u = p[1]
|
||
# `ssh://gitea@host:2345/x.git`, `gitea@host:x.git`, `https://host/x.git`
|
||
h = u.split("://", 1)[-1].split("/", 1)[0].split("@")[-1].split(":")[0]
|
||
(dentro if h in mios else fuera).append(u)
|
||
if dentro:
|
||
res.append({"path": r, "dentro": " ".join(dentro),
|
||
"fuera": " ".join(fuera),
|
||
"solo_aqui": "si" if not fuera else "no"})
|
||
return res
|
||
|
||
|
||
def sitios_web(t):
|
||
"""Los sitios declarados en la config, con su raíz y si esa raíz EXISTE.
|
||
|
||
── POR QUÉ ESTO NO LO PODÍA DECIR EL DNS ───────────────────────────────────────────────────
|
||
La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Un bloque
|
||
cuyo `root` apunta a un directorio que NO existe es un fósil que devuelve 502 — y eso se lee del
|
||
fichero: **sin red, sin pedirle una página al servidor, sin arriesgar su fail2ban**. Medido en
|
||
gioser: 3 sitios (`aura`, `sigma`, `kosmofono`) apuntan a raíces borradas, lo que explica los
|
||
502 que en su día hicieron que el censo se baneara a sí mismo al sondearlos por HTTP.
|
||
|
||
Y sirve para lo contrario: un directorio que la config SÍ referencia no es un huérfano. Sin esto
|
||
el censo marcó `/var/www/git-tawasuyu` como «nadie lo recuerda» cuando es un sitio vivo."""
|
||
import formatos
|
||
lectores, _ = formatos.cargar()
|
||
res = []
|
||
for patron, fmt in _CONFS:
|
||
if fmt not in lectores:
|
||
continue
|
||
ficheros = [patron]
|
||
if "*" in patron:
|
||
out, ok = t.run(f"ls -1 {patron} 2>/dev/null")
|
||
ficheros = out.split() if ok and out.strip() else []
|
||
for f in ficheros:
|
||
texto, ok = t.run(f"cat {f} 2>/dev/null")
|
||
if not ok or not texto.strip() or texto.startswith("(sonda falló"):
|
||
continue
|
||
try:
|
||
cfg = lectores[fmt][0](texto, f)
|
||
except Exception as e:
|
||
# Un parser que revienta NO puede tumbar el censo entero, pero tampoco callarse:
|
||
# sin esto uno cree que el servidor no declara sitios, que es lo contrario del dato.
|
||
res.append({"config": f, "formato": fmt, "nombres": "", "raiz": "",
|
||
"raiz_existe": "error", "detalle": f"no se pudo leer: {e}"[:120]})
|
||
continue
|
||
for s in cfg.sitios:
|
||
# El `root` del sitio Y los de sus rutas anidadas: un `handle` con su propio `root`
|
||
# 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"]})
|
||
if raices:
|
||
guion = "; ".join(f'[ -d {shlex.quote(d)} ] && echo "SI {d}" || echo "NO {d}"' for d in raices)
|
||
out, _ = t.run(guion)
|
||
estado = {}
|
||
for l in out.splitlines():
|
||
p = l.split(None, 1)
|
||
if len(p) == 2:
|
||
estado[p[1]] = "si" if p[0] == "SI" else "no"
|
||
for r in res:
|
||
if r["raiz"]:
|
||
r["raiz_existe"] = estado.get(r["raiz"], "?")
|
||
return res
|
||
|
||
|
||
def dominios(t):
|
||
"""Los dominios que la máquina DICE servir, contrastados con DNS y con un GET real.
|
||
|
||
Las tres respuestas posibles importan y son distintas: sin DNS (nadie lo resuelve), con DNS pero
|
||
5xx (backend caído), y con DNS y 200 (vivo). Y hay una cuarta que sólo se ve mirando a DÓNDE
|
||
resuelve: un dominio que responde 200 desde OTRA IP ya está mudado, y su bloque de config acá
|
||
es un fósil."""
|
||
confs = ["/etc/caddy/Caddyfile", "/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/*",
|
||
"/etc/apache2/sites-enabled/*", "/etc/httpd/conf.d/*"]
|
||
out, _ = t.run("cat " + " ".join(confs) + " 2>/dev/null")
|
||
nombres = set()
|
||
for l in out.splitlines():
|
||
s = l.strip()
|
||
if s.startswith("#"):
|
||
continue
|
||
# caddy: «a.com, b.com {» · nginx/apache: «server_name a.com b.com;» / «ServerName a.com»
|
||
if s.endswith("{") and not s.startswith(("@", "}")):
|
||
cand = s[:-1]
|
||
elif s.lower().startswith(("server_name", "servername", "serveralias")):
|
||
cand = s.split(None, 1)[1].rstrip(";") if len(s.split(None, 1)) > 1 else ""
|
||
else:
|
||
continue
|
||
for d in re.split(r'[,\s]+', cand):
|
||
d = d.strip().rstrip(";")
|
||
if re.fullmatch(r'[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9-]+)+', d, re.I):
|
||
nombres.add(d.lower())
|
||
return sorted(nombres)
|
||
|
||
|
||
def sondear_dominio(d, ips_maquina, http=True):
|
||
"""DNS (+ HTTP opcional) desde donde corre el censo. Determinista, sin juicio.
|
||
|
||
⚠⚠ **NO se hace contra la máquina que uno está censando desde ella misma.** «Sólo lee» era falso:
|
||
leer POR RED tiene efectos. Medido el 2026-09-11 — censar gioser en modo `--local` disparó 29
|
||
peticiones HTTPS a sus propios dominios, varios devuelven 502, y su `fail2ban` (jaula
|
||
`caddy-backend-down`) **baneó la propia IP de la máquina**. Resultado: gioser dejó de poder hablar
|
||
con su propio gitea y los `git push` empezaron a fallar con «Could not read from remote
|
||
repository». Un censo que deja la máquina incomunicada no es de sólo lectura.
|
||
|
||
Y además es metodológicamente mejor: lo que interesa es **qué ve el mundo**, no qué ve la máquina
|
||
de sí misma. Por eso la sonda se hace desde donde corre el censo —el operador o la caja nueva— y
|
||
se SALTA cuando se censa en local, salvo `--probe` explícito.
|
||
|
||
── PERO EL DNS NO TOCA AL ORIGEN ───────────────────────────────────────────────────────────
|
||
Lo que dispara el fail2ban es el HTTP, no la resolución: `getent` le pregunta al DNS, no al
|
||
servidor. Con `--probe-dns` se sondea SÓLO el nombre, y eso ya contesta la pregunta que más
|
||
pesa en una mudanza — **cuáles de estos dominios siguen apuntando acá y cuáles son fósiles**.
|
||
Lo que el DNS solo NO puede decir es si el backend contesta, así que esos dominios quedan en
|
||
`apunta-aca`, que es una clase propia y no `vivo`: decir «vivo» sin haber pedido una página
|
||
sería exactamente la respuesta falsa con forma de respuesta que este censo existe para evitar."""
|
||
ip = ""
|
||
try:
|
||
r = subprocess.run(["getent", "ahostsv4", d], capture_output=True, text=True, timeout=10)
|
||
if r.returncode == 0 and r.stdout.strip():
|
||
ip = r.stdout.split()[0]
|
||
except Exception:
|
||
pass
|
||
code = ""
|
||
if ip and http:
|
||
try:
|
||
r = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}",
|
||
"--max-time", "8", f"https://{d}/"], capture_output=True, text=True, timeout=20)
|
||
code = r.stdout.strip() or "000"
|
||
except Exception:
|
||
pass
|
||
if not ip:
|
||
clase = "fosil-sin-dns"
|
||
elif ip not in ips_maquina:
|
||
clase = "ya-mudado" # resuelve a otra máquina: el bloque de acá es fósil
|
||
elif not http:
|
||
clase = "apunta-aca" # resuelve acá; si SIRVE no se preguntó, y no se inventa
|
||
elif code.startswith(("2", "3")):
|
||
clase = "vivo"
|
||
else:
|
||
clase = "backend-caido"
|
||
return {"domain": d, "dns": ip, "http": code, "class": clase}
|
||
|
||
|
||
def datos(t, raices):
|
||
"""Qué pesa y dónde. Sin esto no se puede dimensionar la mudanza — y `du` sobre hardlinks MIENTE
|
||
para dimensionar una COPIA (medido: 60 G por `du`, 85 G al copiar sin `-H`), así que se reportan
|
||
los dos números."""
|
||
res = []
|
||
for r in raices:
|
||
out, ok = t.run(f"du -sh {shlex.quote(r)} 2>/dev/null | cut -f1; "
|
||
f"du -sh --count-links {shlex.quote(r)} 2>/dev/null | cut -f1")
|
||
ls = out.splitlines()
|
||
if ok and ls:
|
||
res.append({"path": r, "size": ls[0],
|
||
"size_copy": ls[1] if len(ls) > 1 else ls[0]})
|
||
return res
|
||
|
||
|
||
def montajes(t):
|
||
out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ")
|
||
res = []
|
||
for l in out.splitlines():
|
||
f = l.split()
|
||
if len(f) >= 6:
|
||
res.append({"dev": f[0], "size": f[1], "used": f[2], "avail": f[3], "mount": f[-1]})
|
||
return res
|
||
|
||
|
||
def homes(t):
|
||
"""Los homes REALES de la máquina, leídos de `/etc/passwd`. Root siempre; los demás sólo si su
|
||
shell no es `nologin`/`false` (un home de cuenta de servicio no guarda config de nadie).
|
||
|
||
── POR QUÉ NO ALCANZA CON `~` ──────────────────────────────────────────────────────────────
|
||
El censo corre por SSH como root, así que `~` se expande a `/root` y el home de la PERSONA no se
|
||
mira nunca. En el censo de gioser la huella era que `~/.ssh` y `/root/.ssh` salían idénticos, y
|
||
lo que quedó sin censar fue la clave privada de release, el token de `gh` y 6,8 G de `~/.claude`.
|
||
Nada de eso falla el día que se borra la máquina: falla la próxima vez que alguien firma.
|
||
"""
|
||
out, _ = t.run("getent passwd 2>/dev/null || cat /etc/passwd 2>/dev/null")
|
||
res, vistos = [], set()
|
||
for linea in out.splitlines():
|
||
f = linea.split(":")
|
||
if len(f) < 7:
|
||
continue
|
||
user, home, shell = f[0], f[5], f[6]
|
||
if not home or home in ("/", "/nonexistent", "/dev/null", "/bin", "/sbin"):
|
||
continue
|
||
if user != "root" and shell.rsplit("/", 1)[-1] in ("nologin", "false", "sync", ""):
|
||
continue
|
||
if home in vistos:
|
||
continue
|
||
vistos.add(home)
|
||
res.append({"user": user, "home": home})
|
||
return res
|
||
|
||
|
||
def _rutas_candidatas():
|
||
"""Lee `rutas-fuera-de-git.txt` (al lado de este guión). Devuelve [(ruta, motivo)].
|
||
|
||
La lista vive en un fichero de DATOS y no en un literal de Python porque una lista cableada se
|
||
queda vieja sin que nada falle: la anterior preguntaba por `~/.config/hammer`, muerto desde el
|
||
renombre, y no preguntaba por `~/.config/takana`, que es donde está la clave de firma.
|
||
"""
|
||
ruta = os.path.join(os.path.dirname(os.path.abspath(__file__)), "rutas-fuera-de-git.txt")
|
||
out = []
|
||
with open(ruta, encoding="utf-8") as fh:
|
||
for linea in fh:
|
||
linea = linea.strip()
|
||
if not linea or linea.startswith("#"):
|
||
continue
|
||
p, _, motivo = linea.partition("#")
|
||
p = p.strip()
|
||
if p:
|
||
out.append((p, motivo.strip()))
|
||
return out
|
||
|
||
|
||
def fuera_de_git(t):
|
||
"""Lo que NO viaja con el repo y sin lo cual el servidor nuevo no funciona. Se reporta la
|
||
EXISTENCIA y los nombres de entrada, nunca el contenido: un censo no copia secretos.
|
||
|
||
── TRES ESTADOS, NO DOS ────────────────────────────────────────────────────────────────────
|
||
`existe` / `no existe` no alcanza: si el censo corre sin privilegio para leer el home de otro,
|
||
un `ls` que falla se lee igual que un directorio ausente y el resultado es un censo que dice
|
||
«no hay nada» de lo que no pudo mirar. Por eso hay `sin_permiso`, que es un dato distinto y va
|
||
al resumen: lo ausente se descarta, lo no mirado se vuelve a mirar con quien pueda.
|
||
|
||
⚠ Y el tercer estado hay que buscarlo TAMBIÉN hacia arriba (2026-09-16). La primera versión sólo
|
||
lo detectaba cuando la ruta se podía *stat*ear pero no listar; cuando el que no deja pasar es un
|
||
ANCESTRO, `[ -e ]` contesta que no existe y la ruta caía en `ausente`, que el censo descarta sin
|
||
decir nada. Medido en gioser: `/root/.local` es 0700, así que un censo corrido como `sergio`
|
||
reportaba `/root/.local/bin` como si no estuviera — **572 M de binarios puestos a mano que
|
||
ningún paquete provee**, justo la clase que la mudanza no puede reconstruir. Corriendo como
|
||
root aparecen. Ahora, si un ancestro existe y no es atravesable, el estado es `sin_permiso`.
|
||
|
||
Todo en UNA sola llamada (un script generado) y no una por ruta: por SSH, N rutas × M homes son
|
||
decenas de conexiones y el censo tarda más que el trabajo que describe.
|
||
"""
|
||
cands = _rutas_candidatas()
|
||
hs = homes(t)
|
||
objetivos = [] # (ruta_absoluta, motivo, dueño)
|
||
for p, motivo in cands:
|
||
if p.startswith("~/"):
|
||
for h in hs:
|
||
objetivos.append((os.path.join(h["home"], p[2:]), motivo, h["user"]))
|
||
else:
|
||
objetivos.append((p, motivo, ""))
|
||
|
||
prog = ["set -u"]
|
||
for ruta, _, _ in objetivos:
|
||
q = shlex.quote(ruta)
|
||
prog.append(
|
||
f'p={q}; '
|
||
# `-e` responde NO tanto para «no está» como para «no puedo mirar»: si un ANCESTRO no es
|
||
# atravesable, el test falla igual que si la ruta no existiera. Medido: /root/.local es
|
||
# 0700, y un censo corrido como `sergio` daba /root/.local/bin por AUSENTE — 572 M de
|
||
# binarios a mano que ningún paquete provee, desaparecidos en silencio del censo.
|
||
'if [ -e "$p" ]; then '
|
||
' if [ -f "$p" ]; then st=fichero; '
|
||
' elif ls -1 "$p" >/dev/null 2>&1; then st=ok; '
|
||
' else st=sin_permiso; fi; '
|
||
'else '
|
||
' st=ausente; d=${p%/*}; '
|
||
' while [ -n "$d" ]; do '
|
||
' if [ -e "$d" ]; then [ -x "$d" ] || st=sin_permiso; break; fi; '
|
||
' d=${d%/*}; '
|
||
' done; '
|
||
'fi; '
|
||
'sz=$(timeout 20 du -shx "$p" 2>/dev/null | cut -f1); '
|
||
'printf "P\t%s\t%s\t%s\n" "$p" "$st" "${sz:-?}"; '
|
||
'[ "$st" = ok ] && ls -1 "$p" 2>/dev/null | head -20 | sed "s/^/E\t/"; '
|
||
'true'
|
||
)
|
||
out, _ = t.run("\n".join(prog))
|
||
|
||
por_ruta = {}
|
||
actual = None
|
||
for linea in out.splitlines():
|
||
campos = linea.split("\t")
|
||
if campos[0] == "P" and len(campos) >= 4:
|
||
actual = {"path": campos[1], "estado": campos[2], "size": campos[3], "entries": []}
|
||
por_ruta[campos[1]] = actual
|
||
elif campos[0] == "E" and actual is not None and len(campos) >= 2:
|
||
actual["entries"].append(campos[1])
|
||
|
||
res = []
|
||
for ruta, motivo, duenio in objetivos:
|
||
r = por_ruta.get(ruta)
|
||
if not r or r["estado"] == "ausente":
|
||
continue # lo que no está no se reporta; lo que no se pudo mirar SÍ (abajo)
|
||
res.append({"path": ruta, "owner": duenio, "porque": motivo,
|
||
"estado": r["estado"], "size": r["size"], "entries": r["entries"]})
|
||
|
||
out, _ = t.run("crontab -l 2>/dev/null | grep -vE '^#|^$' | wc -l")
|
||
if out.isdigit() and int(out) > 0:
|
||
res.append({"path": "crontab", "owner": "", "porque": "el latido: lo que esta máquina "
|
||
"dispara sola y nadie recuerda", "estado": "ok", "size": "-",
|
||
"entries": [f"{out} líneas"]})
|
||
return res
|
||
|
||
|
||
def contenido_web(t, raices=("/var/www", "/srv")):
|
||
"""Qué hay en las raíces de contenido web, con su tamaño. Un nivel, sin juicio.
|
||
|
||
── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────
|
||
La regla 2 del plan dice que lo que muere se nombra «por su nombre Y CON SU TAMAÑO». Los
|
||
servicios y los datos ya lo cumplían; los DOMINIOS no — se listaban pelados, y un dominio fósil
|
||
no pesa nada por sí mismo: pesa lo que dejó en disco. `terapeuta.ec` no resuelve en ningún DNS y
|
||
tiene 279 M en `/var/www/terapeuta`. Que eso se supiera dependía de que alguien se acordara.
|
||
|
||
⚠ **No se afirma la correspondencia, se OFRECE.** El directorio se llama `terapeuta` y el
|
||
dominio `terapeuta.ec`: no coinciden. Una regla difusa que empareje «casi» acierta la mayoría de
|
||
las veces y el resto de las veces manda a borrar lo que no era. Se listan los candidatos con su
|
||
tamaño y decide el humano — y se listan TAMBIÉN los directorios que no se parecen a ningún
|
||
dominio, que son los que nadie recuerda (`gioser.bak.20260501`, `ogisoer`)."""
|
||
res = []
|
||
for raiz in raices:
|
||
out, ok = t.run(f"du -sh {raiz}/* 2>/dev/null | sort -rh | head -40")
|
||
if not ok or not out.strip():
|
||
continue
|
||
for l in out.splitlines():
|
||
p = l.split(None, 1)
|
||
if len(p) == 2 and p[1].startswith("/"):
|
||
res.append({"path": p[1], "size": p[0], "raiz": raiz})
|
||
return res
|
||
|
||
|
||
def candidatos_de(dominio, contenido):
|
||
"""Directorios que PODRÍAN ser de este dominio. Por etiqueta principal, no por nombre completo."""
|
||
d = dominio.lower()
|
||
etiquetas = [x for x in d.split(".") if x not in ("www", "com", "net", "org", "ec", "io", "dev")]
|
||
principal = etiquetas[-1] if etiquetas else d
|
||
out = []
|
||
for c in contenido:
|
||
base = os.path.basename(c["path"]).lower()
|
||
if principal and (principal in base or base.split(".")[0] in etiquetas):
|
||
out.append(c)
|
||
return out
|
||
|
||
|
||
# ── clasificación ───────────────────────────────────────────────────────────────────────────────
|
||
|
||
# Nombres que NO son servicios: son el andamiaje del sistema o hijos de un servicio. Contarlos como
|
||
# `no-declarado` es ruido, y un censo con hallazgos falsos se ignora entero.
|
||
NO_SERVICIOS = {
|
||
"agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo",
|
||
"bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl",
|
||
# Restos de tubería reparentados a PID 1 cuando su padre muere. Medido: `head -15` figuraba como
|
||
# servicio `no-declarado` con ppid==1. No se esconden —salen en `descartados`, porque un huérfano
|
||
# ES un hallazgo— pero no son servicios y contarlos como tales es el ruido que mata un censo.
|
||
"head", "tr", "cut", "grep", "wc", "find", "xargs", "tee", "sort", "uniq", "env", "nohup",
|
||
"timeout", "flock", "tar", "gzip", "zstd", "dd", "md5sum", "sha256sum", "less", "more",
|
||
}
|
||
|
||
|
||
def normalizar(n):
|
||
"""Un mismo servicio se llama distinto en cada sitio: OpenRC dice `act-runner`, `ps` dice
|
||
`act_runner`, systemd dice `act-runner.service`, y los getty por tty son `agetty.tty1`,
|
||
`agetty-tty2`… Sin normalizar, el MISMO proceso sale a la vez como `no-declarado` y como
|
||
`declarado-muerto`, que son las dos clases que más importan. Medido en gioser: pasaba con
|
||
`act_runner`."""
|
||
n = n.strip().lower()
|
||
for suf in (".service", ".socket", ".timer", ".target"):
|
||
if n.endswith(suf):
|
||
n = n[: -len(suf)]
|
||
n = n.replace("_", "-")
|
||
n = re.sub(r'[.\-]tty\d+$', '', n) # agetty.tty1 / agetty-tty2 → agetty
|
||
n = re.sub(r'\d+$', '', n) if re.fullmatch(r'[a-z-]+\d+', n) else n
|
||
return n
|
||
|
||
|
||
def emparenta(vivo, declarado):
|
||
"""¿Son el mismo servicio con distinto nombre? Los desajustes son sistemáticos, no caprichosos:
|
||
el DEMONIO y la UNIDAD se llaman distinto. Medidos en gioser: `crond`↔`cronie`,
|
||
`dbus-daemon`↔`dbus`, `fail2ban-server`↔`fail2ban`, `elogind-daemon`↔`elogind`.
|
||
|
||
Sin esto, cada uno de ésos aparece a la vez como `no-declarado` (falso: sí está declarado) y como
|
||
`declarado-muerto` (falso: sí está corriendo) — las dos clases que más importan, las dos mal."""
|
||
if vivo == declarado:
|
||
return True
|
||
for suf in ("-daemon", "-server", "-bin"):
|
||
if vivo.endswith(suf) and vivo[: -len(suf)] == declarado:
|
||
return True
|
||
# `crond` → `cron`, prefijo de `cronie`. `sshd` → `ssh`. Sólo quitando UNA `d` final y exigiendo
|
||
# prefijo: quitar letras a ciegas emparenta cosas que no tienen nada que ver.
|
||
if len(vivo) > 4 and vivo.endswith("d") and declarado.startswith(vivo[:-1]):
|
||
return True
|
||
return False
|
||
|
||
|
||
def es_nombre_de_servicio(n):
|
||
"""Descarta lo que claramente no es un servicio: versiones (`2.1.240`), rutas, vacíos. Salieron
|
||
de parsear `rc-status -a`, cuya salida incluye líneas que no son unidades."""
|
||
return bool(re.fullmatch(r'[a-z][a-z0-9@._+-]{1,40}', n)) and not re.fullmatch(r'[\d.]+', n)
|
||
|
||
|
||
def clasificar(vivos, decl):
|
||
"""Las tres clases. `no-declarado` es la importante: corre y nadie sabe cómo levantarlo."""
|
||
# índice normalizado: nombre-canónico → fuentes que lo declaran
|
||
idx = {}
|
||
for fuente, nombres in decl.items():
|
||
for n in nombres:
|
||
if es_nombre_de_servicio(n.lower()):
|
||
idx.setdefault(normalizar(n), set()).add(fuente)
|
||
|
||
out, vistos, descartados = [], set(), []
|
||
for nombre, pids in sorted(vivos.items()):
|
||
canon = normalizar(nombre)
|
||
if canon in NO_SERVICIOS or not es_nombre_de_servicio(canon):
|
||
# No se esconde: un proceso con ppid==1 que no es un servicio suele ser un HUÉRFANO, y
|
||
# eso es un hallazgo (procesos fugados que retienen locks, por ejemplo).
|
||
descartados.append(nombre)
|
||
continue
|
||
fuentes = sorted(idx.get(canon, set()))
|
||
if not fuentes: # segunda pasada: demonio vs unidad
|
||
emparentados = [f for d, fs in idx.items() if emparenta(canon, d) for f in fs]
|
||
fuentes = sorted(set(emparentados))
|
||
if fuentes:
|
||
vistos.update(d for d in idx if emparenta(canon, d))
|
||
vistos.add(canon)
|
||
out.append({"name": nombre, "pids": pids,
|
||
"class": "vivo" if fuentes else "no-declarado",
|
||
"declared_in": fuentes})
|
||
for canon in sorted(set(idx) - vistos):
|
||
if canon in NO_SERVICIOS:
|
||
continue
|
||
out.append({"name": canon, "pids": [],
|
||
"class": "declarado-muerto",
|
||
"declared_in": sorted(idx[canon])})
|
||
return out, descartados
|
||
|
||
|
||
# ── salida ──────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
def toml_escape(s):
|
||
return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||
|
||
|
||
def emitir_toml(c):
|
||
L = []
|
||
L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo")
|
||
L.append("# para DECIDIR: la etapa siguiente lee `decision` de cada entrada.")
|
||
L.append(f"schema = \"takana-censo/1\"")
|
||
L.append(f"fecha = {toml_escape(c['fecha'])}")
|
||
L.append("")
|
||
L.append("[maquina]")
|
||
for k, v in c["identidad"].items():
|
||
L.append(f"{k} = {toml_escape(v) if isinstance(v, str) else json.dumps(v)}")
|
||
L.append("")
|
||
for m in c["montajes"]:
|
||
L.append("[[montaje]]")
|
||
for k, v in m.items():
|
||
L.append(f"{k} = {toml_escape(v)}")
|
||
L.append("")
|
||
for s in c["servicios"]:
|
||
L.append("[[servicio]]")
|
||
L.append(f"name = {toml_escape(s['name'])}")
|
||
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'])}")
|
||
# ⚠ 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',''))}")
|
||
if s.get('usuario'):
|
||
L.append(f"usuario = {toml_escape(s['usuario'])}")
|
||
if s.get('exec_borrado'):
|
||
L.append("exec_borrado = true")
|
||
L.append(f"pid_vivo = {int(s.get('pid_vivo') or 0)}")
|
||
L.append(f"bytes_vivo = {toml_escape(s.get('bytes_vivo',''))}")
|
||
L.append(f"bytes_disco = {toml_escape(s.get('bytes_disco',''))}")
|
||
if s.get('exec_motivo'):
|
||
L.append(f"exec_motivo = {toml_escape(s['exec_motivo'])}")
|
||
L.append(f"paquete = {toml_escape(s.get('paquete',''))}")
|
||
L.append('decision = "" # muda | muere | (vacío = sin decidir)')
|
||
L.append("")
|
||
for x in c.get("repos", []):
|
||
L.append("[[repo]]")
|
||
for k, v in x.items():
|
||
L.append(f"{k} = {toml_escape(v)}")
|
||
L.append("")
|
||
for x in c.get("sitios", []):
|
||
L.append("[[sitio]]")
|
||
for k, v in x.items():
|
||
L.append(f"{k} = {toml_escape(v)}")
|
||
L.append("")
|
||
for x in c.get("contenido", []):
|
||
L.append("[[contenido]]")
|
||
for k, v in x.items():
|
||
L.append(f"{k} = {toml_escape(v)}")
|
||
L.append("")
|
||
for d in c["dominios"]:
|
||
L.append("[[dominio]]")
|
||
for k, v in d.items():
|
||
L.append(f"{k} = {toml_escape(v)}")
|
||
L.append('decision = "" # muda | muere | (vacío = sin decidir)')
|
||
L.append("")
|
||
for d in c["datos"]:
|
||
L.append("[[datos]]")
|
||
for k, v in d.items():
|
||
L.append(f"{k} = {toml_escape(v)}")
|
||
# ⚠ `destino` existe porque la ruta de origen NO tiene por qué caber en la misma ruta del
|
||
# destino. Medido contra la caja takana: `/` tiene 3,5 G libres y los datos de gioser son
|
||
# 33,6 G — el sitio está en `/work` (66 G) y en `/store` (19 G). Sin este campo el plan
|
||
# copiaba ruta→misma ruta y la mudanza no entraba, con el detalle de que el fallo aparece a
|
||
# mitad de un rsync de 22 G.
|
||
L.append('destino = "" # vacío = la MISMA ruta en el destino. Ej: "/work/home"')
|
||
L.append('decision = "" # muda | muere | (vacío = sin decidir)')
|
||
L.append("")
|
||
for f in c["fuera_de_git"]:
|
||
L.append("[[fuera_de_git]]")
|
||
L.append(f"path = {toml_escape(f['path'])}")
|
||
if f.get("owner"):
|
||
L.append(f"owner = {toml_escape(f['owner'])}")
|
||
L.append(f"estado = {toml_escape(f.get('estado', 'ok'))}")
|
||
L.append(f"size = {toml_escape(f.get('size', '?'))}")
|
||
if f.get("porque"):
|
||
L.append(f"porque = {toml_escape(f['porque'])}")
|
||
L.append(f"entries = {json.dumps(f['entries'])}")
|
||
L.append('destino = "" # vacío = la MISMA ruta en el destino')
|
||
L.append('decision = "" # muda | muere | (vacío = sin decidir)')
|
||
L.append("")
|
||
return "\n".join(L) + "\n"
|
||
|
||
|
||
def resumen(c):
|
||
i = c["identidad"]
|
||
print(f"\n══ CENSO de {i['hostname']} ({', '.join(i['ips']) or '?'}) ══")
|
||
print(f" kernel {i['kernel']} · PID 1: {i['pid1']}")
|
||
por_clase = {}
|
||
for s in c["servicios"]:
|
||
por_clase.setdefault(s["class"], []).append(s["name"])
|
||
print("\n── servicios ──")
|
||
for cl in ("vivo", "no-declarado", "declarado-muerto"):
|
||
n = por_clase.get(cl, [])
|
||
marca = "⚠ " if cl == "no-declarado" else " "
|
||
print(f"{marca}{cl:18} {len(n):3} {' '.join(sorted(n)[:8])}{' …' if len(n) > 8 else ''}")
|
||
if c.get("huerfanos"):
|
||
print(f" descartados (no son servicios: herramientas sueltas o HUÉRFANOS con ppid==1): "
|
||
f"{len(c['huerfanos'])} {' '.join(sorted(c['huerfanos'])[:8])}")
|
||
if por_clase.get("no-declarado"):
|
||
print(" ⚠ `no-declarado` = corre y NADIE sabe cómo levantarlo. Es el trabajo de la mudanza.")
|
||
if c["dominios"]:
|
||
print("\n── dominios ──")
|
||
pd = {}
|
||
for d in c["dominios"]:
|
||
pd.setdefault(d["class"], []).append(d["domain"])
|
||
for cl in ("vivo", "apunta-aca", "ya-mudado", "backend-caido", "fosil-sin-dns", "sin-sondear"):
|
||
n = pd.get(cl, [])
|
||
if n:
|
||
print(f" {cl:16} {len(n):3} {' '.join(sorted(n)[:6])}{' …' if len(n) > 6 else ''}")
|
||
fg = c.get("fuera_de_git", [])
|
||
if fg:
|
||
print("\n── fuera de git (config y secretos que no viajan con ningún repo) ──")
|
||
for f in sorted(fg, key=lambda x: (x.get("owner", ""), x["path"])):
|
||
duenio = f" [{f['owner']}]" if f.get("owner") else ""
|
||
print(f" {f['path']:42}{duenio:10} {f.get('size', '?'):>7} {f.get('porque', '')[:52]}")
|
||
ciegos = [f for f in fg if f.get("estado") == "sin_permiso"]
|
||
if ciegos:
|
||
# Un `ls` sin permiso se lee IGUAL que un directorio ausente. Decirlo es la diferencia
|
||
# entre «no hay nada» y «no pude mirar», y sólo una de las dos se puede descartar.
|
||
print(f" ⚠ {len(ciegos)} ruta(s) que el censo NO PUDO MIRAR — no es lo mismo que "
|
||
f"ausentes (corré el censo como su dueño o con privilegio):")
|
||
for f in ciegos:
|
||
print(f" {f['path']}")
|
||
if c["datos"]:
|
||
print("\n── datos ──")
|
||
for d in c["datos"]:
|
||
extra = f" (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else ""
|
||
print(f" {d['path']:28} {d['size']:>7}{extra}")
|
||
print()
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="Censo de una máquina para mudarla. Sólo lee.")
|
||
ap.add_argument("--host", help="usuario@host a censar por SSH")
|
||
ap.add_argument("--key", help="clave SSH")
|
||
ap.add_argument("--local", action="store_true", help="censar esta máquina")
|
||
ap.add_argument("--out", help="fichero TOML de salida (default: stdout sólo el resumen)")
|
||
ap.add_argument("--probe", action="store_true",
|
||
help="sondear los dominios por DNS+HTTP. Por defecto SÓLO con --host: hacerlo "
|
||
"contra uno mismo puede disparar el fail2ban de la propia máquina")
|
||
ap.add_argument("--probe-dns", action="store_true",
|
||
help="sondear SÓLO el DNS (sin HTTP): no toca al origen, así que es seguro "
|
||
"contra uno mismo. Dice qué dominios son fósiles y cuáles apuntan acá")
|
||
ap.add_argument("--no-probe", action="store_true", help="no sondear dominios")
|
||
ap.add_argument("--data-root", action="append", default=[],
|
||
help="raíz de datos a medir (repetible). Default: las habituales")
|
||
a = ap.parse_args()
|
||
if not a.host and not a.local:
|
||
ap.error("hace falta --host o --local")
|
||
|
||
t = Objetivo(a.host, a.key)
|
||
print(" censando… (no se ESCRIBE nada; las sondas de red sí dejan huella: ver --probe)",
|
||
file=sys.stderr)
|
||
|
||
ident = identidad(t)
|
||
vivos = procesos_vivos(t)
|
||
decl = declarados(t)
|
||
svc, huerfanos = clasificar(vivos, decl)
|
||
inv = invocaciones(t, vivos)
|
||
for e in svc:
|
||
i = inv.get(e['name'], {})
|
||
e['cmdline'] = i.get('cmdline', '')
|
||
e['cwd'] = i.get('cwd', '')
|
||
e['exe_real'] = i.get('exe', '')
|
||
if i.get('exe_borrado'):
|
||
e['exec_borrado'] = True
|
||
e['pid_vivo'] = i.get('pid', 0)
|
||
e['bytes_vivo'] = i.get('bytes_vivo', '')
|
||
e['bytes_disco'] = i.get('bytes_disco', '')
|
||
if i.get('usuario'):
|
||
e['usuario'] = i['usuario']
|
||
# `/proc/<pid>/exe` MANDA sobre `argv[0]`: es el binario de verdad y no lo puede reescribir el
|
||
# proceso. `argv[0]` queda de respaldo para cuando la sonda no tuvo permiso.
|
||
def _exe_de(e):
|
||
return e.get('exe_real') or (
|
||
(e.get('cmdline') or '').split(' ')[0] if (e.get('cmdline') or '').startswith('/') else '')
|
||
ejes = [x for x in (_exe_de(e) for e in svc) if x]
|
||
dn = duenos(t, ejes)
|
||
raiz = os.geteuid() == 0 if a.local else None
|
||
for e in svc:
|
||
exe = _exe_de(e)
|
||
e['exec'] = exe
|
||
e['paquete'] = dn.get(exe, '')
|
||
if not exe and e.get('pids'):
|
||
# Decir POR QUÉ no se sabe. «Averigualo a mano» y «volvé a correr el censo con sudo» son
|
||
# dos trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
|
||
e['exec_motivo'] = ("el censo no corrió como root: `/proc/<pid>/exe` de un proceso de "
|
||
"otro usuario no se puede leer. Repetilo con sudo"
|
||
if raiz is False else
|
||
"el proceso reescribió su argv[0] y no se pudo leer /proc/<pid>/exe")
|
||
pts = puertos(t)
|
||
nombres = dominios(t)
|
||
cont = contenido_web(t)
|
||
sitios = sitios_web(t)
|
||
sondear = (a.probe or a.probe_dns or bool(a.host)) and not a.no_probe
|
||
http_ok = not a.probe_dns
|
||
if nombres and not sondear:
|
||
print(f" ⚠ {len(nombres)} dominio(s) declarados, SIN sondear: censando en local, y pedirle",
|
||
file=sys.stderr)
|
||
print(" a la máquina sus propios dominios puede disparar su fail2ban y dejarla",
|
||
file=sys.stderr)
|
||
print(" incomunicada (pasó). Sondealos desde otra máquina, o forzá con --probe.",
|
||
file=sys.stderr)
|
||
doms = [{"domain": d, "dns": "", "http": "", "class": "sin-sondear"} for d in nombres]
|
||
else:
|
||
doms = []
|
||
for i, d in enumerate(nombres):
|
||
if i and http_ok:
|
||
time.sleep(0.5) # no parecer un escaneo ante el propio fail2ban del objetivo
|
||
doms.append(sondear_dominio(d, set(ident["ips"]), http_ok))
|
||
raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store"]
|
||
dat = datos(t, raices)
|
||
# Después de `raices`, que es de dónde salen los repos a mirar. (Estaba antes y `raices` todavía
|
||
# no existía: habría reventado con NameError en la primera corrida con datos.)
|
||
repos = repos_git(t, raices + ["/mnt/vvv"], ident.get("ips", []), nombres)
|
||
censo = {
|
||
"fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||
"identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts,
|
||
"dominios": doms, "datos": dat, "contenido": cont, "sitios": sitios, "repos": repos, "montajes": montajes(t),
|
||
"fuera_de_git": fuera_de_git(t),
|
||
}
|
||
resumen(censo)
|
||
if a.out:
|
||
with open(a.out, "w") as f:
|
||
f.write(emitir_toml(censo))
|
||
print(f" censo → {a.out} (decidí `decision` en cada entrada y pasalo a la etapa 2)")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|