Files
takana/scripts/mudanza/censar.py
T
SergioandClaude Opus 5 1c237f9da2 mudanza: decidir SIN teclas unitarias, /mnt/vvv entra al censo, y 25 rutas de secretos más
Tres cosas que salieron de usar la herramienta de verdad.

1. `--decidir <fichero>`: decidir EN LOTE desde `<ruta-o-nombre> <decision>` por línea.

   El usuario: «no sé cómo activar o desactivar cosas aquí desde shuma llimphi remoto, que las
   opciones son con teclas unitarias». El modo interactivo lee una tecla por entrada, y hay
   terminales donde eso no se puede usar — una herramienta cuya única forma de decidir exige un
   tipo de terminal no es una herramienta, es una herramienta PARA ESA TERMINAL. El fichero anda en
   cualquiera, se revisa antes de aplicarlo, se versiona y se vuelve a correr.

   Una clave que no empareja con nada, o que empareja con varias, es ERROR RUIDOSO y no se escribe
   NADA: un lote a medias deja decidido lo que nadie revisó.

2. `/mnt/vvv` entra a las raíces del censo. No estaba, y ahí vive el trabajo: los repos de la
   persona, el monorepo, el store, work/sources. El agujero se vio preguntando por `humanoid`: el
   censo sólo conocía `/home/sergio/humanoid` —200 M de `build/` de junio— mientras el proyecto de
   verdad, con su `.git`, estaba en `/mnt/vvv/humanoid`, fuera de toda raíz censada. `repos_git` SÍ
   lo miraba: una parte del censo conocía el árbol y la otra no.

3. 25 rutas más en `rutas-fuera-de-git.txt`, del barrido que hizo el frente tawasuyu sobre su home:
   `~/.wawa/seeds` (sin él ninguna app nueva del génesis nace), `~/.tejido` (la identidad de esta
   máquina en la flota), `~/.local/share/agora`, `~/.config/{wawa,minga,thasnuna,shuma,mirada,hcloud}`,
   `~/keys` y `~/fdroid` (⚠ firma de apps Android), `~/.gnupg`, `~/.pgpass`, `/etc/wireguard`,
   `/etc/{shuma,sandokan,tawasuyu/agente,local.d}`. Son identidades y semillas: no se «vuelven a
   generar», porque generar otras significa ser OTRA máquina para el resto de la flota.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:01:16 +00:00

1259 lines
65 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 datos_detallados(t, raices, sitios=(), servicios=()):
"""Un renglón POR COSA, con los hechos que hacen falta para decidir qué hacer con ella.
── POR QUÉ NO ALCANZA CON LA RAÍZ (pedido del usuario, 2026-09-16) ─────────────────────────
`datos()` mide raíces: `/home` = 25 G, una línea, una decisión. Pero adentro hay 40 cosas de
dueños distintos —repos con remoto, SDKs que se re-bajan, caché, y trabajo que no está en
ningún lado— y con UNA decisión para todas sólo se puede acertar a lo bruto: mudar 25 G para
salvar 200 M, o abandonar 25 G y perder ese 200 M. La granularidad de la lista ES la
granularidad de lo que se puede decidir.
── Y LA DESCRIPCIÓN SON HECHOS, NO UN JUICIO ──────────────────────────────────────────────
De cada entrada se reportan cosas COMPROBADAS: si es un repo git y si tiene remoto (⇒ se vuelve
a clonar), la fecha de lo más nuevo que hay dentro, si adentro hay directorios que se regeneran
(`node_modules`, `target`, `venv`), si la config del servidor web lo SIRVE y si un proceso vivo
lo usa. Nada de «parece un proyecto viejo». El que decide es el humano; esto le pone delante lo
que se puede medir, que es justo lo que no tiene a mano cuando mira una lista de nombres.
Todo en UNA llamada por raíz (un script generado), no una por directorio: son ~110 entradas.
"""
# Un nivel; `/home` va a DOS porque su primer nivel son usuarios y la decisión no es por usuario.
objetivos = []
for r in raices:
out, ok = t.run(f"ls -A {shlex.quote(r)} 2>/dev/null")
if not ok or not out.strip():
continue
for n in out.split():
hijo = os.path.join(r, n)
if r == "/home":
sub, ok2 = t.run(f"ls -A {shlex.quote(hijo)} 2>/dev/null")
if ok2 and sub.strip():
objetivos += [(os.path.join(hijo, x), r) for x in sub.split()]
continue
objetivos.append((hijo, r))
# ── EN TANDAS, Y UN FALLO SE DICE ──────────────────────────────────────────────────────────
# `du -sh` sobre 110 directorios (25 G de `/home` entre ellos) no entra en el timeout de una
# sola llamada: la primera versión mandaba el programa entero, se pasaba de tiempo, y como el
# parser no encontraba ni una línea `P` devolvía **lista vacía** — o sea «no hay datos» en vez
# de «no pude medirlos». El mismo modo de fallo que el `-e` del §fuera_de_git, otra vez.
prog_de = {}
for ruta, _ in objetivos:
q = shlex.quote(ruta)
prog_de[ruta] = (
f'p={q}; '
'printf "P\t%s\n" "$p"; '
'sz=$(timeout 60 du -sh "$p" 2>/dev/null | cut -f1); printf "S\t%s\n" "${sz:-?}"; '
'szc=$(timeout 60 du -sh --count-links "$p" 2>/dev/null | cut -f1); '
'printf "C\t%s\n" "${szc:-?}"; '
'[ -d "$p" ] && printf "T\tdir\n" || printf "T\tfichero\n"; '
'if [ -d "$p/.git" ]; then '
' printf "G\t%s\n" "$(git -C "$p" log -1 --format=%cs 2>/dev/null)"; '
' printf "R\t%s\n" "$(git -C "$p" remote get-url origin 2>/dev/null)"; '
' git -C "$p" status --porcelain 2>/dev/null | head -1 | '
' while read -r _l; do printf "D\tsucio\n"; done; '
'fi; '
'for g in node_modules target venv .venv dist build .cache __pycache__; do '
' [ -d "$p/$g" ] && printf "X\t%s\n" "$g"; done; '
'printf "F\t%s\n" "$(timeout 30 find "$p" -maxdepth 3 -type f -printf "%TY-%Tm-%Td\n" '
' 2>/dev/null | sort -r | head -1)"; '
'true'
)
TANDA = 12
rutas = [r for r, _ in objetivos]
salida, fallidas = [], []
for i in range(0, len(rutas), TANDA):
lote = rutas[i:i + TANDA]
out, ok = t.run("set -u\n" + "\n".join(prog_de[r] for r in lote))
if not ok and "P\t" not in out:
fallidas += lote
continue
salida.append(out)
if fallidas:
print(f" ⚠ {len(fallidas)} ruta(s) no se pudieron MEDIR (la sonda falló o tardó de más): "
f"{', '.join(fallidas[:3])}{' …' if len(fallidas) > 3 else ''}", file=sys.stderr)
print(" No es lo mismo que «no hay nada ahí»: no entran al censo y hay que mirarlas.",
file=sys.stderr)
raiz_de = dict(objetivos)
por_ruta, actual = {}, None
for linea in "\n".join(salida).splitlines():
c = linea.split("\t")
if len(c) < 2:
continue
k, v = c[0], c[1]
if k == "P":
actual = {"path": v, "size": "?", "size_copy": "?", "tipo": "dir", "git_fecha": "",
"git_remoto": "", "git_sucio": False, "regenerables": [], "reciente": "",
"raiz": raiz_de.get(v, "")}
por_ruta[v] = actual
elif actual is None:
continue
elif k == "S": actual["size"] = v or "?"
elif k == "C": actual["size_copy"] = v or "?"
elif k == "T": actual["tipo"] = v
elif k == "G": actual["git_fecha"] = v
elif k == "R": actual["git_remoto"] = v
elif k == "D": actual["git_sucio"] = True
elif k == "X": actual["regenerables"].append(v)
elif k == "F": actual["reciente"] = v
# ── los cruces, en Python: son con lo que YA censamos, no con la máquina ────────────────────
raices_servidas = {}
for st in sitios:
if st.get("raiz"):
raices_servidas.setdefault(st["raiz"].rstrip("/"), []).append(st.get("nombres", ""))
res = []
for ruta, _ in objetivos:
e = por_ruta.get(ruta)
if not e:
continue
# ¿lo sirve la config del servidor web? (la ruta servida puede estar POR DEBAJO de ésta)
servido = []
for rs, doms in raices_servidas.items():
if rs == ruta or rs.startswith(ruta.rstrip("/") + "/"):
servido += " ".join(doms).split()
e["servido_por"] = sorted(set(servido))[:4]
# ¿lo usa un proceso VIVO? (su binario, su cwd o un argumento apuntan adentro)
usan = []
for s in servicios:
if s.get("class") == "declarado-muerto":
continue
texto = " ".join([s.get("cmdline", ""), s.get("cwd", ""), s.get("exec", "")])
if ruta in texto:
usan.append(s["name"])
e["usado_por"] = sorted(set(usan))[:4]
e["descripcion"] = _describir_dato(e)
res.append(e)
res.sort(key=lambda x: _bytes_aprox(x["size"]), reverse=True)
return res
def _bytes_aprox(s):
"""`du -sh` da «1.2G» / «288M». Para ORDENAR alcanza con una conversión aproximada; que no sea
exacta no cambia el orden de la lista, y pedir bytes exactos costaría otro `du` sobre 25 G."""
try:
u = s[-1]
n = float(s[:-1].replace(",", "."))
return n * {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}.get(u, 1)
except Exception:
return 0.0
def _describir_dato(e):
"""La línea corta que acompaña a cada cosa en la lista de decisiones. HECHOS separados por `·`,
el más decisivo primero. Sin adjetivos: «tiene remoto» se puede discutir, «parece obsoleto» no."""
t = []
if e["servido_por"]:
t.append("LO SIRVE " + ", ".join(e["servido_por"]))
if e["usado_por"]:
t.append("lo usa " + ", ".join(e["usado_por"]))
if e["git_remoto"]:
t.append(f"repo git → {e['git_remoto']}" + (" · CON CAMBIOS SIN COMMITEAR" if e["git_sucio"] else ""))
elif e["git_fecha"]:
t.append(f"repo git SIN remoto (último commit {e['git_fecha']})")
if e["regenerables"]:
t.append("adentro: " + "/".join(e["regenerables"]) + " (se regenera)")
if e["reciente"]:
t.append(f"lo más nuevo: {e['reciente']}")
return " · ".join(t) if t else "sin señales: ni git, ni servido, ni usado por nada vivo"
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 _campo_decision(e, con_destino=False):
"""Las líneas `destino`/`decision` de una entrada, respetando lo que YA tenga.
⚠ Sin esto, re-emitir un censo DECIDIDO escribe la clave dos veces y el TOML deja de parsear —
o sea que la herramienta no podía volver a escribir su propio fichero, que es justo lo que hace
falta para que las decisiones tomadas en `--decide` sobrevivan a la corrida."""
L = []
if con_destino:
d = e.get("destino", "")
L.append(f'destino = {toml_escape(d)} # vacío = la MISMA ruta en el destino. Ej: "/work/home"')
dec = e.get("decision", "")
L.append(f'decision = {toml_escape(dec)} # muda | respalda | muere | (vacío = sin decidir)')
return L
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 += _campo_decision(s)
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():
if k == "decision":
continue
L.append(f"{k} = {toml_escape(v)}")
L += _campo_decision(d)
L.append("")
for d in c["datos"]:
L.append("[[datos]]")
for k, v in d.items():
if k in ("decision", "destino"):
continue
# `regenerables`/`servido_por`/`usado_por` son listas y `git_sucio` un booleano: pasarlos
# por `toml_escape` los dejaría como la cadena "[\'x\']", que parsea y MIENTE.
if isinstance(v, (list, bool)):
L.append(f"{k} = {json.dumps(v)}")
else:
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 += _campo_decision(d, con_destino=True)
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 += _campo_decision(f, con_destino=True)
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.get("datos_raiz"):
print("\n── datos, por raíz ──")
for d in c["datos_raiz"]:
extra = f" (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else ""
print(f" {d['path']:28} {d['size']:>7}{extra}")
if c["datos"] and c["datos"] is not c.get("datos_raiz"):
# ⚠ Se imprimen las 15 más grandes y SE DICE cuántas quedaron fuera. Cortar en silencio es
# lo que hace que una lista parezca completa: el fichero las trae todas, y quien decide
# tiene que saber que la pantalla no es el censo.
print(f"\n── datos, cosa por cosa ({len(c['datos'])} entradas; el TOML las trae todas) ──")
for d in c["datos"][:15]:
print(f" {d['path']:44} {d['size']:>6} {d.get('descripcion','')[:64]}")
if len(c["datos"]) > 15:
print(f" … y {len(c['datos']) - 15} más en el fichero")
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("--solo-totales", action="store_true",
help="medir los datos POR RAÍZ y no cosa por cosa (más rápido, pero deja "
"25 G de `/home` en una sola decisión)")
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))
# ⚠ `/mnt/vvv` ENTRA (2026-09-16). No estaba, y ahí vive el trabajo: los repos de la persona, el
# monorepo, el store y `work/sources`. El censo listaba 169 cosas y ninguna era el árbol donde
# está todo — el agujero apareció cuando el usuario preguntó por `humanoid` y el censo sólo
# conocía `/home/sergio/humanoid`, que son 200 M de `build/` viejo: el proyecto de verdad, con
# su `.git`, estaba en `/mnt/vvv/humanoid`, fuera de toda raíz censada.
# (`repos_git` sí lo miraba: una parte del censo conocía el árbol y la otra no.)
raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store", "/mnt/vvv"]
# Dos vistas y no una: el TOTAL por raíz es lo que dimensiona la mudanza (y lo que mira el
# preflight), y el DETALLE por cosa es lo que se puede decidir. Dar sólo el total obliga a
# decidir 25 G de una; dar sólo el detalle pierde el número que dice si entra en el destino.
dat_raiz = datos(t, raices)
dat = datos_detallados(t, raices, sitios, svc) if not a.solo_totales else dat_raiz
# 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, "datos_raiz": dat_raiz, "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()