gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1534Shell 199Rust 109Markdown 993713 Definitions

⎇ main scripts/mudanza/censar.py Python · 1258 lines · 65.2 KB History Who touched what raw

mudanza: decidir SIN teclas unitarias, `/mnt/vvv` entra al censo, y 25 rutas de secretos más 1c237f9da · Sergio ·

1#!/usr/bin/env python3
2"""censar.py — CENSO de una máquina: qué corre, qué sirve, qué datos tiene. Sin IA, sin adivinar.
3
4⚠ «Sólo lee» hay que decirlo con cuidado: NO ESCRIBE nada, pero las sondas de RED dejan huella. Ver
5`sondear_dominio` — censar gioser contra sí mismo hizo que su propio fail2ban lo baneara.
6
7Primer paso de la mudanza (SDD 29). Produce un inventario TOML que un humano revisa y que las etapas
8siguientes consumen. NO toca nada: todo lo que hace son lecturas.
9
10── LA REGLA QUE LO ORDENA TODO ─────────────────────────────────────────────────────────────────
11Cruzar SIEMPRE lo DECLARADO contra lo VIVO, y reportar las tres clases por separado:
12
13    vivo            declarado Y corriendo        ⇒ candidato a mudarse
14    declarado-muerto  declarado y sin proceso    ⇒ candidato a morir
15    no-declarado    corriendo y sin declaración  ⇒ EL TRABAJO REAL: nadie sabe cómo levantarlo
16
17No es teoría. Medido sobre gioser (2026-09-11):
18  · `rc-status` decía `stopped` de caddy, gitea, sshd, cronie y act-runner, y los CINCO estaban
19    vivos colgando de PID 1. Un censo que lea sólo el init habría producido un plan que no arranca.
20  · De los 19 dominios del Caddyfile, 8 no tenían DNS, 2 daban 502 y 3 ya vivían en OTRA máquina.
21    Sólo 6 eran reales. Un censo que lea sólo la config habría mudado 13 fósiles.
22  · Y el propio caddy de la caja NUEVA murió en un reinicio sin que nadie lo notara, porque estaba
23    arrancado a mano y no declarado. Esa es la clase `no-declarado`, y es la que duele.
24
25── POR QUÉ CADA SONDA ──────────────────────────────────────────────────────────────────────────
26`ppid==1` es la verdad de qué corre, no el init. Los sockets en LISTEN dicen qué SIRVE. El DNS y un
27GET dicen si alguien lo usa. `du` dice qué pesa. Nada de esto necesita juicio: son hechos, y el
28juicio (qué se muda, qué muere) lo pone el usuario en la etapa siguiente, sobre este fichero.
29
30Uso:
31    scripts/mudanza/censar.py --host root@1.2.3.4 [--key ~/.ssh/id] [--out censo.toml]
32    scripts/mudanza/censar.py --local                       # censa esta máquina
33
34La superficie va en inglés (CLAUDE.md regla 4); los mensajes, en castellano.
35"""
36import argparse, json, os, re, shlex, subprocess, sys, time
37
38# ── ejecución de sondas ─────────────────────────────────────────────────────────────────────────
39
40class Objetivo:
41    """Una máquina a censar. `run` devuelve (salida, ok) y NUNCA levanta: una sonda que falla es un
42    dato («esa máquina no tiene ss»), no un motivo para abortar el censo entero."""
43
44    def __init__(self, host=None, key=None, timeout=25):
45        self.host, self.key, self.timeout = host, key, timeout
46
47    def run(self, cmd):
48        if self.host:
49            base = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
50                    "-o", f"ConnectTimeout={self.timeout}", "-o", "BatchMode=yes"]
51            if self.key:
52                base += ["-i", os.path.expanduser(self.key)]
53            argv = base + [self.host, cmd]
54        else:
55            argv = ["sh", "-c", cmd]
56        try:
57            p = subprocess.run(argv, capture_output=True, text=True, timeout=self.timeout * 4)
58            return p.stdout.strip(), p.returncode == 0
59        except Exception as e:
60            return f"(sonda falló: {e})", False
61
62
63# ── sondas ──────────────────────────────────────────────────────────────────────────────────────
64
65def identidad(t):
66    """Quién es esta máquina. El hostname MIENTE (gioser dice `momento`): la IP es el discriminador."""
67    out, _ = t.run("hostname; ip -4 addr show scope global 2>/dev/null | grep -o 'inet [0-9.]*' | "
68                   "cut -d' ' -f2 | head -3; uname -r; cat /proc/1/comm")
69    ls = [l for l in out.splitlines() if l.strip()]
70    return {
71        "hostname": ls[0] if ls else "?",
72        "ips": [l for l in ls[1:-2]] if len(ls) > 3 else [],
73        "kernel": ls[-2] if len(ls) >= 2 else "?",
74        "pid1": ls[-1] if ls else "?",
75    }
76
77
78def invocaciones(t, pids):
79    """La LÍNEA DE COMANDO y el `cwd` de cada servicio vivo, leídos de `/proc`.
80
81    Es el dato que convierte `no-declarado` de «acá hay un problema» en «acá está la solución»: si
82    nadie sabe cómo levantar un servicio, lo que hace falta para declararlo es exactamente cómo se
83    está ejecutando ahora. Sin esto el plan sólo puede decir «declaralo», que es lo que uno ya sabía.
84
85    ⚠ Se lee `cmdline` y `cwd`, NO el entorno: `/proc/<pid>/environ` suele traer tokens y
86    contraseñas, y un censo que se guarda en un fichero y se manda por ahí no puede llevarlos."""
87    out = {}
88    for nombre, ps in pids.items():
89        pid = ps[0]
90        # ⚠ SONDAS SEPARADAS, y es un bug pagado: iban en un solo comando, y como `readlink
91        # /proc/<pid>/cwd` necesita permiso de ptrace (dueño o root), en cualquier proceso de root
92        # el comando entero salía ≠0 y se descartaba TAMBIÉN el `cmdline`, que sí se podía leer.
93        # Resultado: 26 de 37 servicios sin invocación, justo el dato que da sentido al censo.
94        # Una sonda que falla es un dato; no puede arrastrarse a las que funcionaron.
95        cmd, _ = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null")
96        cwd, _ = t.run(f"readlink /proc/{pid}/cwd 2>/dev/null")
97        # ⚠ Tercera sonda, y también separada. Muchos demonios REESCRIBEN su `argv[0]` para decir en
98        # qué andan (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la
99        # línea de comando NO dice cuál es el binario. `/proc/<pid>/exe` sí — pero necesita permiso
100        # de ptrace, o sea ser el dueño o root. Medido en gioser con uid 1001: 7 de 38 servicios
101        # quedaban en «no se pudo leer su binario» por esto, y no por ser realmente desconocidos.
102        exe, _ = t.run(f"readlink /proc/{pid}/exe 2>/dev/null")
103        cmd, cwd, exe = cmd.strip(), cwd.strip(), exe.strip()
104        # ⚠⚠ `/proc/<pid>/exe` termina en « (deleted)» cuando el fichero YA NO ESTÁ en disco: el
105        # proceso sostiene el inodo y nadie más lo puede abrir por su ruta. Medido en gioser: CUATRO
106        # servicios están así, y en dos de ellos hay AHORA otro fichero en la misma ruta, de distinto
107        # tamaño — `tejido` corre 12 750 368 bytes y en su ruta hay 13 815 864. Copiar la ruta muda
108        # OTRA COSA, en silencio, y el servicio nuevo no es el que estaba andando.
109        #
110        # Se recupera leyendo `/proc/<pid>/exe`, y SÓLO mientras el proceso viva: al reiniciar gioser
111        # —o al morirse ese pid— ese binario desaparece para siempre. En una mudanza que termina
112        # BORRANDO el origen, esto es lo más urgente del censo.
113        borrado = exe.endswith(" (deleted)")
114        if borrado:
115            exe = exe[:-len(" (deleted)")]
116        vivo_b = disco_b = ""
117        if borrado:
118            v, _ = t.run(f"stat -L -c %s /proc/{pid}/exe 2>/dev/null")
119            d, _ = t.run(f"stat -c %s {shlex.quote(exe)} 2>/dev/null")
120            vivo_b = v.strip() if v.strip().isdigit() else ""
121            disco_b = d.strip() if d.strip().isdigit() else ""
122        for v in ("cmd", "cwd", "exe"):
123            if locals()[v].startswith("(sonda falló"):
124                cmd, cwd, exe = (("" if v == "cmd" else cmd), ("" if v == "cwd" else cwd),
125                                 ("" if v == "exe" else exe))
126        sup = SUPERVISADOS.get(pid)
127        if sup:
128            cmd, exe = sup["cmdline"], sup["exec"]
129        if cmd or cwd or exe:
130            out[nombre] = {"cmdline": cmd, "cwd": cwd,
131                           "exe": exe if exe.startswith("/") else "",
132                           "usuario": (sup or {}).get("usuario", ""),
133                           "exe_borrado": borrado, "pid": pid,
134                           "bytes_vivo": vivo_b, "bytes_disco": disco_b}
135    return out
136
137
138def duenos(t, ejecutables):
139    """¿Quién PROVEE el binario de cada servicio? Determinista, preguntándole al gestor de paquetes
140    del origen.
141
142    Es lo que convierte «instalá el paquete» —que es lo que uno ya sabía— en una instrucción: o el
143    binario lo trae un paquete con nombre, o NO lo trae nadie y entonces es un compilado suelto que
144    hay que llevar o reconstruir. La diferencia decide el trabajo, y no se adivina.
145
146    Una consulta por lote, no una por servicio: son decenas de servicios y cada `ssh` cuesta."""
147    if not ejecutables:
148        return {}
149    lista = " ".join(shlex.quote(e) for e in sorted(set(ejecutables)))
150    # Se prueba el gestor que exista. `2>/dev/null || echo -` ⇒ un fichero sin dueño no aborta el lote.
151    guion = (
152        'for f in %s; do '
153        '  if command -v pacman >/dev/null 2>&1; then o=$(pacman -Qoq "$f" 2>/dev/null); '
154        '  elif command -v dpkg >/dev/null 2>&1; then o=$(dpkg -S "$f" 2>/dev/null | cut -d: -f1); '
155        '  elif command -v apk >/dev/null 2>&1; then o=$(apk info -W "$f" 2>/dev/null | tail -1 '
156        '       | sed "s/.* is owned by //;s/-[0-9].*//"); '
157        '  elif command -v rpm >/dev/null 2>&1; then o=$(rpm -qf "$f" 2>/dev/null); '
158        '  else o=""; fi; '
159        '  echo "$f|${o:-}"; done'
160    ) % lista
161    out, _ = t.run(guion)
162    res = {}
163    for l in out.splitlines():
164        if "|" in l:
165            f, o = l.split("|", 1)
166            res[f.strip()] = o.strip()
167    return res
168
169
170# Envoltorios que NO son el servicio: el nombre de verdad está en sus argumentos. Sin desenvolverlos,
171# los CINCO `supervise-daemon` de gioser se juntaban en UNA entrada llamada `supervise-daemo` — cinco
172# servicios distintos (shuma-daemon, dbus, metalog…) colapsados en una tarjeta con el nombre del
173# envoltorio. Un servicio de más es ruido; cinco servicios fundidos en uno es un plan que no arranca.
174SUPERVISORES = {"supervise-daemon", "start-stop-daemon", "s6-supervise", "runsv", "daemonize"}
175_COMM_SUP = {s[:15] for s in SUPERVISORES}
176
177# pid → lo que el supervisor manda de verdad. Se llena al listar los procesos y se consulta al leer
178# `/proc`: el pid con ppid==1 es el SUPERVISOR, así que su `cmdline` y su `exe` describen al
179# envoltorio, no al servicio.
180SUPERVISADOS = {}
181
182
183def _mandado_por_supervisor(args):
184    """El comando REAL que hay detrás de `supervise-daemon`, y con qué usuario corre.
185
186    El formato es `supervise-daemon <servicio> --start [opciones] <ejecutable> -- [argumentos]`, así
187    que el ejecutable es el ÚLTIMO token antes del `--` suelto. Comprobado contra los cinco de gioser
188    (dbus, dhcpcd, metalog, squid, shuma-daemon).
189
190    ⚠ Sin esto, el censo guardaba como `cmdline` la línea del SUPERVISOR — y una tarjeta de arje
191    hecha con eso arrancaría `supervise-daemon` DENTRO de arje, que ya supervisa. Y el `exec` salía
192    `/usr/bin/supervise-daemon`, o sea que `squid` figuraba como provisto por el paquete `openrc`.
193
194    ⚠⚠ Y se rescata el `--user`: `shuma-daemon` corre como `sergio`. Perder ese dato al desenvolver
195    sería la misma elisión que el escritor de arje reporta como SIN-TRADUCIR — un servicio que en el
196    origen corría sin privilegios terminaría de root en el destino, y nadie se enteraría."""
197    p = args.split()
198    if "--start" not in p:
199        return "", "", ""
200    resto = p[p.index("--start") + 1:]
201    usuario = ""
202    for i, x in enumerate(resto):
203        if x in ("--user", "-u") and i + 1 < len(resto):
204            usuario = resto[i + 1]
205            break
206    corte = resto.index("--") if "--" in resto else len(resto)
207    izq, der = resto[:corte], resto[corte + 1:]
208    if not izq or not izq[-1].startswith("/"):
209        return "", "", usuario
210    return izq[-1], " ".join([izq[-1]] + der), usuario
211
212
213def _desenvolver(comm, args):
214    """(nombre_real, args_reales) para un proceso que puede ser un envoltorio."""
215    p = args.split()
216    if not p:
217        return comm, args
218    base = os.path.basename(p[0])
219    if base not in SUPERVISORES and comm not in {s[:15] for s in SUPERVISORES}:
220        return comm, args
221    if base in ("supervise-daemon", "s6-supervise", "runsv") and len(p) > 1:
222        # `supervise-daemon <servicio> --start …` · `runsv /etc/service/<servicio>`
223        return os.path.basename(p[1].rstrip("/")), args
224    for i, x in enumerate(p):                      # start-stop-daemon
225        if x in ("--name", "-n") and i + 1 < len(p):
226            return p[i + 1], args
227        if x in ("--exec", "-x", "--startas", "-a") and i + 1 < len(p):
228            return os.path.basename(p[i + 1]), args
229    return comm, args
230
231
232def _destruncar(comm, args):
233    """`comm` viene del kernel y está capado a 15 caracteres: `willay-crosscheck` llega como
234    `willay-crossche`. Ese nombre truncado no es cosmético — es el que el plan mete en
235    `--in /etc/init.d/<nombre>` (ruta que no existe) y el que va de `label` en la tarjeta de arje.
236    La línea de comando sí trae el nombre entero, así que se usa ESA cuando confirma al truncado."""
237    if len(comm) < 15:
238        return comm
239    p = args.split()
240    if p:
241        base = os.path.basename(p[0])
242        if base.startswith(comm):
243            return base
244    return comm
245
246
247def procesos_vivos(t):
248    """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración."""
249    out, _ = t.run("ps -eo pid=,ppid=,comm=,args= 2>/dev/null | awk '$2==1 && $1!=1'")
250    vivos = {}
251    for l in out.splitlines():
252        parts = l.split(None, 3)
253        if len(parts) < 3 or not parts[0].isdigit():
254            continue
255        pid, comm, args = int(parts[0]), parts[2], (parts[3] if len(parts) > 3 else "")
256        nombre, args = _desenvolver(comm, args)
257        nombre = _destruncar(nombre, args)
258        exe, linea, usuario = _mandado_por_supervisor(args) if comm[:15] in _COMM_SUP else ("", "", "")
259        if exe:
260            SUPERVISADOS[pid] = {"exec": exe, "cmdline": linea, "usuario": usuario}
261        vivos.setdefault(nombre, []).append(pid)
262    # los kernel threads no son servicios
263    return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq",
264                                                                "migration", "rcu_", "irq/", "idle_"))}
265
266
267def puertos(t):
268    """Qué SIRVE esta máquina, y quién. Un servicio sin puerto no se muda igual que uno con puerto."""
269    out, ok = t.run("ss -lntpH 2>/dev/null || netstat -lntp 2>/dev/null | tail -n +3")
270    res = []
271    for l in out.splitlines():
272        m = re.search(r'(\S+):(\d+)\s', l)
273        proc = re.search(r'\("([^"]+)"|/(\S+)\s*$', l)
274        # El PID es lo que ata el puerto a un servicio sin ambigüedad. Ver comentario en la
275        # asignación: por NOMBRE, un `shuma` declarado-muerto se quedaba con el puerto del
276        # `shuma-gateway` que lo escucha de verdad.
277        pidm = re.search(r'pid=(\d+)', l)
278        res.append({"addr": m.group(1), "port": int(m.group(2)),
279                    "proc": (proc.group(1) or proc.group(2)) if proc else "?",
280                    "pid": int(pidm.group(1)) if pidm else 0}) if m else None
281    # dedup por (puerto, proc)
282    vistos, uniq = set(), []
283    for r in sorted(res, key=lambda x: x["port"]):
284        k = (r["port"], r["proc"], r.get("pid", 0))
285        if k not in vistos:
286            vistos.add(k); uniq.append(r)
287    return uniq
288
289
290def declarados(t):
291    """Lo que el init DICE que hay. Se compara contra lo vivo — no se cree."""
292    fuentes = {}
293    # `rc-status -a` lista TODO lo disponible en el sistema, no lo que esta máquina usa: en gioser
294    # daba 100 «declarado-muerto» que no significan nada. `rc-update show` da lo ENCENDIDO por
295    # runlevel, que es lo que de verdad se declara.
296    out, ok = t.run("rc-update show 2>/dev/null | awk '{print $1}' | grep -E '^[a-z]'")
297    if ok and out:
298        fuentes["openrc"] = out.split()
299    out, ok = t.run("systemctl list-units --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}'")
300    if ok and out:
301        fuentes["systemd"] = [s.replace(".service", "") for s in out.split()]
302    out, ok = t.run("ls /etc/arje/cards.d/ 2>/dev/null")
303    if ok and out:
304        fuentes["arje-cards"] = [s.replace(".json", "") for s in out.split()]
305    out, ok = t.run("python3 -c \"import json;print(' '.join(n['label'] for n in "
306                    "json.load(open('/ente/seed.card.json'))['genesis']))\" 2>/dev/null")
307    if ok and out:
308        fuentes["arje-seed"] = out.split()
309    out, ok = t.run("ls /etc/service /run/runit/service 2>/dev/null")
310    if ok and out:
311        fuentes["runit"] = out.split()
312    return fuentes
313
314
315# Config del servidor web → qué sitio apunta a qué directorio. Se lee con los LECTORES del centro de
316# traducción (`formatos/`), no con un parser propio: ya existen para caddy, nginx y apache, y tener
317# dos parsers del mismo formato es cómo se separan sin que nadie lo note.
318_CONFS = [("/etc/caddy/Caddyfile", "caddy"),
319          ("/etc/nginx/nginx.conf", "nginx"),
320          ("/etc/nginx/sites-enabled/*", "nginx"),
321          ("/etc/apache2/sites-enabled/*", "apache"),
322          ("/etc/httpd/conf.d/*", "apache")]
323
324
325def repos_git(t, raices, ips, dominios):
326    """Repositorios git bajo los datos, y si su remoto vive EN ESTA MÁQUINA.
327
328    ── LA DEPENDENCIA CIRCULAR QUE MATA LA MUDANZA AL FINAL ─────────────────────────────────────
329    Medido en gioser: **25 repositorios cuyo único remoto es el gitea de la propia máquina** — entre
330    ellos `tawasuyu`, que produce 10 de los 13 binarios que nadie más provee. Apagar el origen no
331    borra sólo unos servicios: borra **el código con el que se volverían a construir**, y los clones
332    de trabajo están en el mismo disco que también muere.
333
334    Es un fallo que no se ve desde ninguna otra parte del censo —el repo no es un proceso, ni un
335    puerto, ni un dominio— y que sólo se descubre cuando ya no hay de dónde sacarlo.
336
337    Un remoto que apunta AFUERA es la prueba de que el código sobrevive. Por eso lo que se reporta no
338    es «tiene remoto» sino **si alguno de sus remotos NO es esta máquina**."""
339    if not raices:
340        return []
341    busca = " ".join(shlex.quote(r) for r in raices)
342    out, ok = t.run(f"find {busca} -maxdepth 4 -name .git -prune 2>/dev/null | head -300")
343    if not ok or not out.strip():
344        return []
345    mios = set(dominios) | set(ips) | {"localhost", "127.0.0.1"}
346    res = []
347    for g in out.split():
348        r = g[:-5] if g.endswith("/.git") else g
349        urls, ok2 = t.run(f"git -C {shlex.quote(r)} config --get-regexp "
350                          f"'remote\\..*\\.(url|pushurl)' 2>/dev/null")
351        if not ok2 or not urls.strip() or urls.startswith("(sonda falló"):
352            continue
353        dentro, fuera = [], []
354        for l in urls.splitlines():
355            p = l.split(None, 1)
356            if len(p) != 2:
357                continue
358            u = p[1]
359            # `ssh://gitea@host:2345/x.git`, `gitea@host:x.git`, `https://host/x.git`
360            h = u.split("://", 1)[-1].split("/", 1)[0].split("@")[-1].split(":")[0]
361            (dentro if h in mios else fuera).append(u)
362        if dentro:
363            res.append({"path": r, "dentro": " ".join(dentro),
364                        "fuera": " ".join(fuera),
365                        "solo_aqui": "si" if not fuera else "no"})
366    return res
367
368
369def sitios_web(t):
370    """Los sitios declarados en la config, con su raíz y si esa raíz EXISTE.
371
372    ── POR QUÉ ESTO NO LO PODÍA DECIR EL DNS ───────────────────────────────────────────────────
373    La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Un bloque
374    cuyo `root` apunta a un directorio que NO existe es un fósil que devuelve 502 — y eso se lee del
375    fichero: **sin red, sin pedirle una página al servidor, sin arriesgar su fail2ban**. Medido en
376    gioser: 3 sitios (`aura`, `sigma`, `kosmofono`) apuntan a raíces borradas, lo que explica los
377    502 que en su día hicieron que el censo se baneara a sí mismo al sondearlos por HTTP.
378
379    Y sirve para lo contrario: un directorio que la config SÍ referencia no es un huérfano. Sin esto
380    el censo marcó `/var/www/git-tawasuyu` como «nadie lo recuerda» cuando es un sitio vivo."""
381    import formatos
382    lectores, _ = formatos.cargar()
383    res = []
384    for patron, fmt in _CONFS:
385        if fmt not in lectores:
386            continue
387        ficheros = [patron]
388        if "*" in patron:
389            out, ok = t.run(f"ls -1 {patron} 2>/dev/null")
390            ficheros = out.split() if ok and out.strip() else []
391        for f in ficheros:
392            texto, ok = t.run(f"cat {f} 2>/dev/null")
393            if not ok or not texto.strip() or texto.startswith("(sonda falló"):
394                continue
395            try:
396                cfg = lectores[fmt][0](texto, f)
397            except Exception as e:
398                # Un parser que revienta NO puede tumbar el censo entero, pero tampoco callarse:
399                # sin esto uno cree que el servidor no declara sitios, que es lo contrario del dato.
400                res.append({"config": f, "formato": fmt, "nombres": "", "raiz": "",
401                            "raiz_existe": "error", "detalle": f"no se pudo leer: {e}"[:120]})
402                continue
403            for s in cfg.sitios:
404                # El `root` del sitio Y los de sus rutas anidadas: un `handle` con su propio `root`
405                # referencia disco igual, y no verlo es lo que hacía pasar por huérfano a un sitio vivo.
406                raices = [(s.raiz or "").rstrip("/")] + [
407                    a.valor.rstrip("/") for r in s.rutas for a in r.acciones if a.tipo == "archivos"]
408                # A DÓNDE PROXEA este sitio: la config declara de qué servicios depende, y eso es
409                # lo único que puede cazar «este dominio se muda y el servicio que lo sirve se mata».
410                prox = [a.valor for a in s.acciones if a.tipo == "proxy"] + [
411                    a.valor for r in s.rutas for a in r.acciones if a.tipo == "proxy"]
412                for raiz in dict.fromkeys(x for x in raices if x) or [""]:
413                    res.append({"config": f, "formato": fmt,
414                                "nombres": " ".join(s.nombres), "raiz": raiz,
415                                "proxies": " ".join(dict.fromkeys(prox)),
416                                "raiz_existe": "", "detalle": ""})
417    # Un solo comando para todas las raíces: una sonda por directorio sobre SSH es lentísima.
418    raices = sorted({r["raiz"] for r in res if r["raiz"]})
419    if raices:
420        guion = "; ".join(f'[ -d {shlex.quote(d)} ] && echo "SI {d}" || echo "NO {d}"' for d in raices)
421        out, _ = t.run(guion)
422        estado = {}
423        for l in out.splitlines():
424            p = l.split(None, 1)
425            if len(p) == 2:
426                estado[p[1]] = "si" if p[0] == "SI" else "no"
427        for r in res:
428            if r["raiz"]:
429                r["raiz_existe"] = estado.get(r["raiz"], "?")
430    return res
431
432
433def dominios(t):
434    """Los dominios que la máquina DICE servir, contrastados con DNS y con un GET real.
435
436    Las tres respuestas posibles importan y son distintas: sin DNS (nadie lo resuelve), con DNS pero
437    5xx (backend caído), y con DNS y 200 (vivo). Y hay una cuarta que sólo se ve mirando a DÓNDE
438    resuelve: un dominio que responde 200 desde OTRA IP ya está mudado, y su bloque de config acá
439    es un fósil."""
440    confs = ["/etc/caddy/Caddyfile", "/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/*",
441             "/etc/apache2/sites-enabled/*", "/etc/httpd/conf.d/*"]
442    out, _ = t.run("cat " + " ".join(confs) + " 2>/dev/null")
443    nombres = set()
444    for l in out.splitlines():
445        s = l.strip()
446        if s.startswith("#"):
447            continue
448        # caddy: «a.com, b.com {» · nginx/apache: «server_name a.com b.com;» / «ServerName a.com»
449        if s.endswith("{") and not s.startswith(("@", "}")):
450            cand = s[:-1]
451        elif s.lower().startswith(("server_name", "servername", "serveralias")):
452            cand = s.split(None, 1)[1].rstrip(";") if len(s.split(None, 1)) > 1 else ""
453        else:
454            continue
455        for d in re.split(r'[,\s]+', cand):
456            d = d.strip().rstrip(";")
457            if re.fullmatch(r'[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9-]+)+', d, re.I):
458                nombres.add(d.lower())
459    return sorted(nombres)
460
461
462def sondear_dominio(d, ips_maquina, http=True):
463    """DNS (+ HTTP opcional) desde donde corre el censo. Determinista, sin juicio.
464
465    ⚠⚠ **NO se hace contra la máquina que uno está censando desde ella misma.** «Sólo lee» era falso:
466    leer POR RED tiene efectos. Medido el 2026-09-11 — censar gioser en modo `--local` disparó 29
467    peticiones HTTPS a sus propios dominios, varios devuelven 502, y su `fail2ban` (jaula
468    `caddy-backend-down`) **baneó la propia IP de la máquina**. Resultado: gioser dejó de poder hablar
469    con su propio gitea y los `git push` empezaron a fallar con «Could not read from remote
470    repository». Un censo que deja la máquina incomunicada no es de sólo lectura.
471
472    Y además es metodológicamente mejor: lo que interesa es **qué ve el mundo**, no qué ve la máquina
473    de sí misma. Por eso la sonda se hace desde donde corre el censo —el operador o la caja nueva— y
474    se SALTA cuando se censa en local, salvo `--probe` explícito.
475
476    ── PERO EL DNS NO TOCA AL ORIGEN ───────────────────────────────────────────────────────────
477    Lo que dispara el fail2ban es el HTTP, no la resolución: `getent` le pregunta al DNS, no al
478    servidor. Con `--probe-dns` se sondea SÓLO el nombre, y eso ya contesta la pregunta que más
479    pesa en una mudanza — **cuáles de estos dominios siguen apuntando acá y cuáles son fósiles**.
480    Lo que el DNS solo NO puede decir es si el backend contesta, así que esos dominios quedan en
481    `apunta-aca`, que es una clase propia y no `vivo`: decir «vivo» sin haber pedido una página
482    sería exactamente la respuesta falsa con forma de respuesta que este censo existe para evitar."""
483    ip = ""
484    try:
485        r = subprocess.run(["getent", "ahostsv4", d], capture_output=True, text=True, timeout=10)
486        if r.returncode == 0 and r.stdout.strip():
487            ip = r.stdout.split()[0]
488    except Exception:
489        pass
490    code = ""
491    if ip and http:
492        try:
493            r = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}",
494                                "--max-time", "8", f"https://{d}/"], capture_output=True, text=True, timeout=20)
495            code = r.stdout.strip() or "000"
496        except Exception:
497            pass
498    if not ip:
499        clase = "fosil-sin-dns"
500    elif ip not in ips_maquina:
501        clase = "ya-mudado"          # resuelve a otra máquina: el bloque de acá es fósil
502    elif not http:
503        clase = "apunta-aca"         # resuelve acá; si SIRVE no se preguntó, y no se inventa
504    elif code.startswith(("2", "3")):
505        clase = "vivo"
506    else:
507        clase = "backend-caido"
508    return {"domain": d, "dns": ip, "http": code, "class": clase}
509
510
511def datos(t, raices):
512    """Qué pesa y dónde. Sin esto no se puede dimensionar la mudanza — y `du` sobre hardlinks MIENTE
513    para dimensionar una COPIA (medido: 60 G por `du`, 85 G al copiar sin `-H`), así que se reportan
514    los dos números."""
515    res = []
516    for r in raices:
517        out, ok = t.run(f"du -sh {shlex.quote(r)} 2>/dev/null | cut -f1; "
518                        f"du -sh --count-links {shlex.quote(r)} 2>/dev/null | cut -f1")
519        ls = out.splitlines()
520        if ok and ls:
521            res.append({"path": r, "size": ls[0],
522                        "size_copy": ls[1] if len(ls) > 1 else ls[0]})
523    return res
524
525
526def datos_detallados(t, raices, sitios=(), servicios=()):
527    """Un renglón POR COSA, con los hechos que hacen falta para decidir qué hacer con ella.
528
529    ── POR QUÉ NO ALCANZA CON LA RAÍZ (pedido del usuario, 2026-09-16) ─────────────────────────
530    `datos()` mide raíces: `/home` = 25 G, una línea, una decisión. Pero adentro hay 40 cosas de
531    dueños distintos —repos con remoto, SDKs que se re-bajan, caché, y trabajo que no está en
532    ningún lado— y con UNA decisión para todas sólo se puede acertar a lo bruto: mudar 25 G para
533    salvar 200 M, o abandonar 25 G y perder ese 200 M. La granularidad de la lista ES la
534    granularidad de lo que se puede decidir.
535
536    ── Y LA DESCRIPCIÓN SON HECHOS, NO UN JUICIO ──────────────────────────────────────────────
537    De cada entrada se reportan cosas COMPROBADAS: si es un repo git y si tiene remoto (⇒ se vuelve
538    a clonar), la fecha de lo más nuevo que hay dentro, si adentro hay directorios que se regeneran
539    (`node_modules`, `target`, `venv`), si la config del servidor web lo SIRVE y si un proceso vivo
540    lo usa. Nada de «parece un proyecto viejo». El que decide es el humano; esto le pone delante lo
541    que se puede medir, que es justo lo que no tiene a mano cuando mira una lista de nombres.
542
543    Todo en UNA llamada por raíz (un script generado), no una por directorio: son ~110 entradas.
544    """
545    # Un nivel; `/home` va a DOS porque su primer nivel son usuarios y la decisión no es por usuario.
546    objetivos = []
547    for r in raices:
548        out, ok = t.run(f"ls -A {shlex.quote(r)} 2>/dev/null")
549        if not ok or not out.strip():
550            continue
551        for n in out.split():
552            hijo = os.path.join(r, n)
553            if r == "/home":
554                sub, ok2 = t.run(f"ls -A {shlex.quote(hijo)} 2>/dev/null")
555                if ok2 and sub.strip():
556                    objetivos += [(os.path.join(hijo, x), r) for x in sub.split()]
557                    continue
558            objetivos.append((hijo, r))
559
560    # ── EN TANDAS, Y UN FALLO SE DICE ──────────────────────────────────────────────────────────
561    # `du -sh` sobre 110 directorios (25 G de `/home` entre ellos) no entra en el timeout de una
562    # sola llamada: la primera versión mandaba el programa entero, se pasaba de tiempo, y como el
563    # parser no encontraba ni una línea `P` devolvía **lista vacía** — o sea «no hay datos» en vez
564    # de «no pude medirlos». El mismo modo de fallo que el `-e` del §fuera_de_git, otra vez.
565    prog_de = {}
566    for ruta, _ in objetivos:
567        q = shlex.quote(ruta)
568        prog_de[ruta] = (
569            f'p={q}; '
570            'printf "P\t%s\n" "$p"; '
571            'sz=$(timeout 60 du -sh "$p" 2>/dev/null | cut -f1); printf "S\t%s\n" "${sz:-?}"; '
572            'szc=$(timeout 60 du -sh --count-links "$p" 2>/dev/null | cut -f1); '
573            'printf "C\t%s\n" "${szc:-?}"; '
574            '[ -d "$p" ] && printf "T\tdir\n" || printf "T\tfichero\n"; '
575            'if [ -d "$p/.git" ]; then '
576            '  printf "G\t%s\n" "$(git -C "$p" log -1 --format=%cs 2>/dev/null)"; '
577            '  printf "R\t%s\n" "$(git -C "$p" remote get-url origin 2>/dev/null)"; '
578            '  git -C "$p" status --porcelain 2>/dev/null | head -1 | '
579            '    while read -r _l; do printf "D\tsucio\n"; done; '
580            'fi; '
581            'for g in node_modules target venv .venv dist build .cache __pycache__; do '
582            '  [ -d "$p/$g" ] && printf "X\t%s\n" "$g"; done; '
583            'printf "F\t%s\n" "$(timeout 30 find "$p" -maxdepth 3 -type f -printf "%TY-%Tm-%Td\n" '
584            '  2>/dev/null | sort -r | head -1)"; '
585            'true'
586        )
587    TANDA = 12
588    rutas = [r for r, _ in objetivos]
589    salida, fallidas = [], []
590    for i in range(0, len(rutas), TANDA):
591        lote = rutas[i:i + TANDA]
592        out, ok = t.run("set -u\n" + "\n".join(prog_de[r] for r in lote))
593        if not ok and "P\t" not in out:
594            fallidas += lote
595            continue
596        salida.append(out)
597    if fallidas:
598        print(f"   ⚠ {len(fallidas)} ruta(s) no se pudieron MEDIR (la sonda falló o tardó de más): "
599              f"{', '.join(fallidas[:3])}{' …' if len(fallidas) > 3 else ''}", file=sys.stderr)
600        print("     No es lo mismo que «no hay nada ahí»: no entran al censo y hay que mirarlas.",
601              file=sys.stderr)
602
603    raiz_de = dict(objetivos)
604    por_ruta, actual = {}, None
605    for linea in "\n".join(salida).splitlines():
606        c = linea.split("\t")
607        if len(c) < 2:
608            continue
609        k, v = c[0], c[1]
610        if k == "P":
611            actual = {"path": v, "size": "?", "size_copy": "?", "tipo": "dir", "git_fecha": "",
612                      "git_remoto": "", "git_sucio": False, "regenerables": [], "reciente": "",
613                      "raiz": raiz_de.get(v, "")}
614            por_ruta[v] = actual
615        elif actual is None:
616            continue
617        elif k == "S": actual["size"] = v or "?"
618        elif k == "C": actual["size_copy"] = v or "?"
619        elif k == "T": actual["tipo"] = v
620        elif k == "G": actual["git_fecha"] = v
621        elif k == "R": actual["git_remoto"] = v
622        elif k == "D": actual["git_sucio"] = True
623        elif k == "X": actual["regenerables"].append(v)
624        elif k == "F": actual["reciente"] = v
625
626    # ── los cruces, en Python: son con lo que YA censamos, no con la máquina ────────────────────
627    raices_servidas = {}
628    for st in sitios:
629        if st.get("raiz"):
630            raices_servidas.setdefault(st["raiz"].rstrip("/"), []).append(st.get("nombres", ""))
631
632    res = []
633    for ruta, _ in objetivos:
634        e = por_ruta.get(ruta)
635        if not e:
636            continue
637        # ¿lo sirve la config del servidor web? (la ruta servida puede estar POR DEBAJO de ésta)
638        servido = []
639        for rs, doms in raices_servidas.items():
640            if rs == ruta or rs.startswith(ruta.rstrip("/") + "/"):
641                servido += " ".join(doms).split()
642        e["servido_por"] = sorted(set(servido))[:4]
643        # ¿lo usa un proceso VIVO? (su binario, su cwd o un argumento apuntan adentro)
644        usan = []
645        for s in servicios:
646            if s.get("class") == "declarado-muerto":
647                continue
648            texto = " ".join([s.get("cmdline", ""), s.get("cwd", ""), s.get("exec", "")])
649            if ruta in texto:
650                usan.append(s["name"])
651        e["usado_por"] = sorted(set(usan))[:4]
652        e["descripcion"] = _describir_dato(e)
653        res.append(e)
654    res.sort(key=lambda x: _bytes_aprox(x["size"]), reverse=True)
655    return res
656
657
658def _bytes_aprox(s):
659    """`du -sh` da «1.2G» / «288M». Para ORDENAR alcanza con una conversión aproximada; que no sea
660    exacta no cambia el orden de la lista, y pedir bytes exactos costaría otro `du` sobre 25 G."""
661    try:
662        u = s[-1]
663        n = float(s[:-1].replace(",", "."))
664        return n * {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}.get(u, 1)
665    except Exception:
666        return 0.0
667
668
669def _describir_dato(e):
670    """La línea corta que acompaña a cada cosa en la lista de decisiones. HECHOS separados por `·`,
671    el más decisivo primero. Sin adjetivos: «tiene remoto» se puede discutir, «parece obsoleto» no."""
672    t = []
673    if e["servido_por"]:
674        t.append("LO SIRVE " + ", ".join(e["servido_por"]))
675    if e["usado_por"]:
676        t.append("lo usa " + ", ".join(e["usado_por"]))
677    if e["git_remoto"]:
678        t.append(f"repo git → {e['git_remoto']}" + (" · CON CAMBIOS SIN COMMITEAR" if e["git_sucio"] else ""))
679    elif e["git_fecha"]:
680        t.append(f"repo git SIN remoto (último commit {e['git_fecha']})")
681    if e["regenerables"]:
682        t.append("adentro: " + "/".join(e["regenerables"]) + " (se regenera)")
683    if e["reciente"]:
684        t.append(f"lo más nuevo: {e['reciente']}")
685    return " · ".join(t) if t else "sin señales: ni git, ni servido, ni usado por nada vivo"
686
687
688def montajes(t):
689    out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ")
690    res = []
691    for l in out.splitlines():
692        f = l.split()
693        if len(f) >= 6:
694            res.append({"dev": f[0], "size": f[1], "used": f[2], "avail": f[3], "mount": f[-1]})
695    return res
696
697
698def homes(t):
699    """Los homes REALES de la máquina, leídos de `/etc/passwd`. Root siempre; los demás sólo si su
700    shell no es `nologin`/`false` (un home de cuenta de servicio no guarda config de nadie).
701
702    ── POR QUÉ NO ALCANZA CON `~` ──────────────────────────────────────────────────────────────
703    El censo corre por SSH como root, así que `~` se expande a `/root` y el home de la PERSONA no se
704    mira nunca. En el censo de gioser la huella era que `~/.ssh` y `/root/.ssh` salían idénticos, y
705    lo que quedó sin censar fue la clave privada de release, el token de `gh` y 6,8 G de `~/.claude`.
706    Nada de eso falla el día que se borra la máquina: falla la próxima vez que alguien firma.
707    """
708    out, _ = t.run("getent passwd 2>/dev/null || cat /etc/passwd 2>/dev/null")
709    res, vistos = [], set()
710    for linea in out.splitlines():
711        f = linea.split(":")
712        if len(f) < 7:
713            continue
714        user, home, shell = f[0], f[5], f[6]
715        if not home or home in ("/", "/nonexistent", "/dev/null", "/bin", "/sbin"):
716            continue
717        if user != "root" and shell.rsplit("/", 1)[-1] in ("nologin", "false", "sync", ""):
718            continue
719        if home in vistos:
720            continue
721        vistos.add(home)
722        res.append({"user": user, "home": home})
723    return res
724
725
726def _rutas_candidatas():
727    """Lee `rutas-fuera-de-git.txt` (al lado de este guión). Devuelve [(ruta, motivo)].
728
729    La lista vive en un fichero de DATOS y no en un literal de Python porque una lista cableada se
730    queda vieja sin que nada falle: la anterior preguntaba por `~/.config/hammer`, muerto desde el
731    renombre, y no preguntaba por `~/.config/takana`, que es donde está la clave de firma.
732    """
733    ruta = os.path.join(os.path.dirname(os.path.abspath(__file__)), "rutas-fuera-de-git.txt")
734    out = []
735    with open(ruta, encoding="utf-8") as fh:
736        for linea in fh:
737            linea = linea.strip()
738            if not linea or linea.startswith("#"):
739                continue
740            p, _, motivo = linea.partition("#")
741            p = p.strip()
742            if p:
743                out.append((p, motivo.strip()))
744    return out
745
746
747def fuera_de_git(t):
748    """Lo que NO viaja con el repo y sin lo cual el servidor nuevo no funciona. Se reporta la
749    EXISTENCIA y los nombres de entrada, nunca el contenido: un censo no copia secretos.
750
751    ── TRES ESTADOS, NO DOS ────────────────────────────────────────────────────────────────────
752    `existe` / `no existe` no alcanza: si el censo corre sin privilegio para leer el home de otro,
753    un `ls` que falla se lee igual que un directorio ausente y el resultado es un censo que dice
754    «no hay nada» de lo que no pudo mirar. Por eso hay `sin_permiso`, que es un dato distinto y va
755    al resumen: lo ausente se descarta, lo no mirado se vuelve a mirar con quien pueda.
756
757    ⚠ Y el tercer estado hay que buscarlo TAMBIÉN hacia arriba (2026-09-16). La primera versión sólo
758    lo detectaba cuando la ruta se podía *stat*ear pero no listar; cuando el que no deja pasar es un
759    ANCESTRO, `[ -e ]` contesta que no existe y la ruta caía en `ausente`, que el censo descarta sin
760    decir nada. Medido en gioser: `/root/.local` es 0700, así que un censo corrido como `sergio`
761    reportaba `/root/.local/bin` como si no estuviera — **572 M de binarios puestos a mano que
762    ningún paquete provee**, justo la clase que la mudanza no puede reconstruir. Corriendo como
763    root aparecen. Ahora, si un ancestro existe y no es atravesable, el estado es `sin_permiso`.
764
765    Todo en UNA sola llamada (un script generado) y no una por ruta: por SSH, N rutas × M homes son
766    decenas de conexiones y el censo tarda más que el trabajo que describe.
767    """
768    cands = _rutas_candidatas()
769    hs = homes(t)
770    objetivos = []          # (ruta_absoluta, motivo, dueño)
771    for p, motivo in cands:
772        if p.startswith("~/"):
773            for h in hs:
774                objetivos.append((os.path.join(h["home"], p[2:]), motivo, h["user"]))
775        else:
776            objetivos.append((p, motivo, ""))
777
778    prog = ["set -u"]
779    for ruta, _, _ in objetivos:
780        q = shlex.quote(ruta)
781        prog.append(
782            f'p={q}; '
783            # `-e` responde NO tanto para «no está» como para «no puedo mirar»: si un ANCESTRO no es
784            # atravesable, el test falla igual que si la ruta no existiera. Medido: /root/.local es
785            # 0700, y un censo corrido como `sergio` daba /root/.local/bin por AUSENTE — 572 M de
786            # binarios a mano que ningún paquete provee, desaparecidos en silencio del censo.
787            'if [ -e "$p" ]; then '
788            '  if [ -f "$p" ]; then st=fichero; '
789            '  elif ls -1 "$p" >/dev/null 2>&1; then st=ok; '
790            '  else st=sin_permiso; fi; '
791            'else '
792            '  st=ausente; d=${p%/*}; '
793            '  while [ -n "$d" ]; do '
794            '    if [ -e "$d" ]; then [ -x "$d" ] || st=sin_permiso; break; fi; '
795            '    d=${d%/*}; '
796            '  done; '
797            'fi; '
798            'sz=$(timeout 20 du -shx "$p" 2>/dev/null | cut -f1); '
799            'printf "P\t%s\t%s\t%s\n" "$p" "$st" "${sz:-?}"; '
800            '[ "$st" = ok ] && ls -1 "$p" 2>/dev/null | head -20 | sed "s/^/E\t/"; '
801            'true'
802        )
803    out, _ = t.run("\n".join(prog))
804
805    por_ruta = {}
806    actual = None
807    for linea in out.splitlines():
808        campos = linea.split("\t")
809        if campos[0] == "P" and len(campos) >= 4:
810            actual = {"path": campos[1], "estado": campos[2], "size": campos[3], "entries": []}
811            por_ruta[campos[1]] = actual
812        elif campos[0] == "E" and actual is not None and len(campos) >= 2:
813            actual["entries"].append(campos[1])
814
815    res = []
816    for ruta, motivo, duenio in objetivos:
817        r = por_ruta.get(ruta)
818        if not r or r["estado"] == "ausente":
819            continue        # lo que no está no se reporta; lo que no se pudo mirar SÍ (abajo)
820        res.append({"path": ruta, "owner": duenio, "porque": motivo,
821                    "estado": r["estado"], "size": r["size"], "entries": r["entries"]})
822
823    out, _ = t.run("crontab -l 2>/dev/null | grep -vE '^#|^$' | wc -l")
824    if out.isdigit() and int(out) > 0:
825        res.append({"path": "crontab", "owner": "", "porque": "el latido: lo que esta máquina "
826                    "dispara sola y nadie recuerda", "estado": "ok", "size": "-",
827                    "entries": [f"{out} líneas"]})
828    return res
829
830
831def contenido_web(t, raices=("/var/www", "/srv")):
832    """Qué hay en las raíces de contenido web, con su tamaño. Un nivel, sin juicio.
833
834    ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────
835    La regla 2 del plan dice que lo que muere se nombra «por su nombre Y CON SU TAMAÑO». Los
836    servicios y los datos ya lo cumplían; los DOMINIOS no — se listaban pelados, y un dominio fósil
837    no pesa nada por sí mismo: pesa lo que dejó en disco. `terapeuta.ec` no resuelve en ningún DNS y
838    tiene 279 M en `/var/www/terapeuta`. Que eso se supiera dependía de que alguien se acordara.
839
840    ⚠ **No se afirma la correspondencia, se OFRECE.** El directorio se llama `terapeuta` y el
841    dominio `terapeuta.ec`: no coinciden. Una regla difusa que empareje «casi» acierta la mayoría de
842    las veces y el resto de las veces manda a borrar lo que no era. Se listan los candidatos con su
843    tamaño y decide el humano — y se listan TAMBIÉN los directorios que no se parecen a ningún
844    dominio, que son los que nadie recuerda (`gioser.bak.20260501`, `ogisoer`)."""
845    res = []
846    for raiz in raices:
847        out, ok = t.run(f"du -sh {raiz}/* 2>/dev/null | sort -rh | head -40")
848        if not ok or not out.strip():
849            continue
850        for l in out.splitlines():
851            p = l.split(None, 1)
852            if len(p) == 2 and p[1].startswith("/"):
853                res.append({"path": p[1], "size": p[0], "raiz": raiz})
854    return res
855
856
857def candidatos_de(dominio, contenido):
858    """Directorios que PODRÍAN ser de este dominio. Por etiqueta principal, no por nombre completo."""
859    d = dominio.lower()
860    etiquetas = [x for x in d.split(".") if x not in ("www", "com", "net", "org", "ec", "io", "dev")]
861    principal = etiquetas[-1] if etiquetas else d
862    out = []
863    for c in contenido:
864        base = os.path.basename(c["path"]).lower()
865        if principal and (principal in base or base.split(".")[0] in etiquetas):
866            out.append(c)
867    return out
868
869
870# ── clasificación ───────────────────────────────────────────────────────────────────────────────
871
872# Nombres que NO son servicios: son el andamiaje del sistema o hijos de un servicio. Contarlos como
873# `no-declarado` es ruido, y un censo con hallazgos falsos se ignora entero.
874NO_SERVICIOS = {
875    "agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo",
876    "bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl",
877    # Restos de tubería reparentados a PID 1 cuando su padre muere. Medido: `head -15` figuraba como
878    # servicio `no-declarado` con ppid==1. No se esconden —salen en `descartados`, porque un huérfano
879    # ES un hallazgo— pero no son servicios y contarlos como tales es el ruido que mata un censo.
880    "head", "tr", "cut", "grep", "wc", "find", "xargs", "tee", "sort", "uniq", "env", "nohup",
881    "timeout", "flock", "tar", "gzip", "zstd", "dd", "md5sum", "sha256sum", "less", "more",
882}
883
884
885def normalizar(n):
886    """Un mismo servicio se llama distinto en cada sitio: OpenRC dice `act-runner`, `ps` dice
887    `act_runner`, systemd dice `act-runner.service`, y los getty por tty son `agetty.tty1`,
888    `agetty-tty2`… Sin normalizar, el MISMO proceso sale a la vez como `no-declarado` y como
889    `declarado-muerto`, que son las dos clases que más importan. Medido en gioser: pasaba con
890    `act_runner`."""
891    n = n.strip().lower()
892    for suf in (".service", ".socket", ".timer", ".target"):
893        if n.endswith(suf):
894            n = n[: -len(suf)]
895    n = n.replace("_", "-")
896    n = re.sub(r'[.\-]tty\d+$', '', n)       # agetty.tty1 / agetty-tty2 → agetty
897    n = re.sub(r'\d+$', '', n) if re.fullmatch(r'[a-z-]+\d+', n) else n
898    return n
899
900
901def emparenta(vivo, declarado):
902    """¿Son el mismo servicio con distinto nombre? Los desajustes son sistemáticos, no caprichosos:
903    el DEMONIO y la UNIDAD se llaman distinto. Medidos en gioser: `crond`↔`cronie`,
904    `dbus-daemon`↔`dbus`, `fail2ban-server`↔`fail2ban`, `elogind-daemon`↔`elogind`.
905
906    Sin esto, cada uno de ésos aparece a la vez como `no-declarado` (falso: sí está declarado) y como
907    `declarado-muerto` (falso: sí está corriendo) — las dos clases que más importan, las dos mal."""
908    if vivo == declarado:
909        return True
910    for suf in ("-daemon", "-server", "-bin"):
911        if vivo.endswith(suf) and vivo[: -len(suf)] == declarado:
912            return True
913    # `crond` → `cron`, prefijo de `cronie`. `sshd` → `ssh`. Sólo quitando UNA `d` final y exigiendo
914    # prefijo: quitar letras a ciegas emparenta cosas que no tienen nada que ver.
915    if len(vivo) > 4 and vivo.endswith("d") and declarado.startswith(vivo[:-1]):
916        return True
917    return False
918
919
920def es_nombre_de_servicio(n):
921    """Descarta lo que claramente no es un servicio: versiones (`2.1.240`), rutas, vacíos. Salieron
922    de parsear `rc-status -a`, cuya salida incluye líneas que no son unidades."""
923    return bool(re.fullmatch(r'[a-z][a-z0-9@._+-]{1,40}', n)) and not re.fullmatch(r'[\d.]+', n)
924
925
926def clasificar(vivos, decl):
927    """Las tres clases. `no-declarado` es la importante: corre y nadie sabe cómo levantarlo."""
928    # índice normalizado: nombre-canónico → fuentes que lo declaran
929    idx = {}
930    for fuente, nombres in decl.items():
931        for n in nombres:
932            if es_nombre_de_servicio(n.lower()):
933                idx.setdefault(normalizar(n), set()).add(fuente)
934
935    out, vistos, descartados = [], set(), []
936    for nombre, pids in sorted(vivos.items()):
937        canon = normalizar(nombre)
938        if canon in NO_SERVICIOS or not es_nombre_de_servicio(canon):
939            # No se esconde: un proceso con ppid==1 que no es un servicio suele ser un HUÉRFANO, y
940            # eso es un hallazgo (procesos fugados que retienen locks, por ejemplo).
941            descartados.append(nombre)
942            continue
943        fuentes = sorted(idx.get(canon, set()))
944        if not fuentes:  # segunda pasada: demonio vs unidad
945            emparentados = [f for d, fs in idx.items() if emparenta(canon, d) for f in fs]
946            fuentes = sorted(set(emparentados))
947            if fuentes:
948                vistos.update(d for d in idx if emparenta(canon, d))
949        vistos.add(canon)
950        out.append({"name": nombre, "pids": pids,
951                    "class": "vivo" if fuentes else "no-declarado",
952                    "declared_in": fuentes})
953    for canon in sorted(set(idx) - vistos):
954        if canon in NO_SERVICIOS:
955            continue
956        out.append({"name": canon, "pids": [],
957                    "class": "declarado-muerto",
958                    "declared_in": sorted(idx[canon])})
959    return out, descartados
960
961
962# ── salida ──────────────────────────────────────────────────────────────────────────────────────
963
964def toml_escape(s):
965    return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
966
967
968def _campo_decision(e, con_destino=False):
969    """Las líneas `destino`/`decision` de una entrada, respetando lo que YA tenga.
970
971    ⚠ Sin esto, re-emitir un censo DECIDIDO escribe la clave dos veces y el TOML deja de parsear —
972    o sea que la herramienta no podía volver a escribir su propio fichero, que es justo lo que hace
973    falta para que las decisiones tomadas en `--decide` sobrevivan a la corrida."""
974    L = []
975    if con_destino:
976        d = e.get("destino", "")
977        L.append(f'destino = {toml_escape(d)}   # vacío = la MISMA ruta en el destino. Ej: "/work/home"')
978    dec = e.get("decision", "")
979    L.append(f'decision = {toml_escape(dec)}   # muda | respalda | muere | (vacío = sin decidir)')
980    return L
981
982
983def emitir_toml(c):
984    L = []
985    L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo")
986    L.append("# para DECIDIR: la etapa siguiente lee `decision` de cada entrada.")
987    L.append(f"schema = \"takana-censo/1\"")
988    L.append(f"fecha = {toml_escape(c['fecha'])}")
989    L.append("")
990    L.append("[maquina]")
991    for k, v in c["identidad"].items():
992        L.append(f"{k} = {toml_escape(v) if isinstance(v, str) else json.dumps(v)}")
993    L.append("")
994    for m in c["montajes"]:
995        L.append("[[montaje]]")
996        for k, v in m.items():
997            L.append(f"{k} = {toml_escape(v)}")
998        L.append("")
999    for s in c["servicios"]:
1000        L.append("[[servicio]]")
1001        L.append(f"name = {toml_escape(s['name'])}")
1002        L.append(f"class = {toml_escape(s['class'])}")
1003        L.append(f"declared_in = {json.dumps(s['declared_in'])}")
1004        L.append(f"pids = {json.dumps(s['pids'])}")
1005        # ⚠ Por PID, no por nombre. Emparejar `proc.startswith(name[:15])` le daba el puerto 7378
1006        # —que escucha `shuma-gateway`, comprobado con `ss -lntp`— al `shuma` DECLARADO-MUERTO, que
1007        # por definición no puede estar escuchando nada. El puerto es la señal más fuerte de que algo
1008        # sirve, así que colgárselo al servicio equivocado envenena todo lo que se derive de él.
1009        _pids = set(s.get("pids") or [])
1010        _mios = [p["port"] for p in c["puertos"] if p.get("pid") and p["pid"] in _pids]
1011        if not _mios and _pids:
1012            # Sin pid en la salida de `ss` (sin permisos, o `netstat` viejo) se cae al nombre, pero
1013            # sólo para servicios VIVOS: un declarado-muerto nunca escucha.
1014            _mios = [p["port"] for p in c["puertos"]
1015                     if not p.get("pid") and p["proc"].startswith(s["name"][:15])]
1016        elif not _pids:
1017            _mios = []
1018        L.append(f"ports = {json.dumps(sorted(set(_mios)))}")
1019        L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}")
1020        L.append(f"cwd = {toml_escape(s.get('cwd',''))}")
1021        L.append(f"exec = {toml_escape(s.get('exec',''))}")
1022        if s.get('usuario'):
1023            L.append(f"usuario = {toml_escape(s['usuario'])}")
1024        if s.get('exec_borrado'):
1025            L.append("exec_borrado = true")
1026            L.append(f"pid_vivo = {int(s.get('pid_vivo') or 0)}")
1027            L.append(f"bytes_vivo = {toml_escape(s.get('bytes_vivo',''))}")
1028            L.append(f"bytes_disco = {toml_escape(s.get('bytes_disco',''))}")
1029        if s.get('exec_motivo'):
1030            L.append(f"exec_motivo = {toml_escape(s['exec_motivo'])}")
1031        L.append(f"paquete = {toml_escape(s.get('paquete',''))}")
1032        L += _campo_decision(s)
1033        L.append("")
1034    for x in c.get("repos", []):
1035        L.append("[[repo]]")
1036        for k, v in x.items():
1037            L.append(f"{k} = {toml_escape(v)}")
1038        L.append("")
1039    for x in c.get("sitios", []):
1040        L.append("[[sitio]]")
1041        for k, v in x.items():
1042            L.append(f"{k} = {toml_escape(v)}")
1043        L.append("")
1044    for x in c.get("contenido", []):
1045        L.append("[[contenido]]")
1046        for k, v in x.items():
1047            L.append(f"{k} = {toml_escape(v)}")
1048        L.append("")
1049    for d in c["dominios"]:
1050        L.append("[[dominio]]")
1051        for k, v in d.items():
1052            if k == "decision":
1053                continue
1054            L.append(f"{k} = {toml_escape(v)}")
1055        L += _campo_decision(d)
1056        L.append("")
1057    for d in c["datos"]:
1058        L.append("[[datos]]")
1059        for k, v in d.items():
1060            if k in ("decision", "destino"):
1061                continue
1062            # `regenerables`/`servido_por`/`usado_por` son listas y `git_sucio` un booleano: pasarlos
1063            # por `toml_escape` los dejaría como la cadena "[\'x\']", que parsea y MIENTE.
1064            if isinstance(v, (list, bool)):
1065                L.append(f"{k} = {json.dumps(v)}")
1066            else:
1067                L.append(f"{k} = {toml_escape(v)}")
1068        # ⚠ `destino` existe porque la ruta de origen NO tiene por qué caber en la misma ruta del
1069        # destino. Medido contra la caja takana: `/` tiene 3,5 G libres y los datos de gioser son
1070        # 33,6 G — el sitio está en `/work` (66 G) y en `/store` (19 G). Sin este campo el plan
1071        # copiaba ruta→misma ruta y la mudanza no entraba, con el detalle de que el fallo aparece a
1072        # mitad de un rsync de 22 G.
1073        L += _campo_decision(d, con_destino=True)
1074        L.append("")
1075    for f in c["fuera_de_git"]:
1076        L.append("[[fuera_de_git]]")
1077        L.append(f"path = {toml_escape(f['path'])}")
1078        if f.get("owner"):
1079            L.append(f"owner = {toml_escape(f['owner'])}")
1080        L.append(f"estado = {toml_escape(f.get('estado', 'ok'))}")
1081        L.append(f"size = {toml_escape(f.get('size', '?'))}")
1082        if f.get("porque"):
1083            L.append(f"porque = {toml_escape(f['porque'])}")
1084        L.append(f"entries = {json.dumps(f['entries'])}")
1085        L += _campo_decision(f, con_destino=True)
1086        L.append("")
1087    return "\n".join(L) + "\n"
1088
1089
1090def resumen(c):
1091    i = c["identidad"]
1092    print(f"\n══ CENSO de {i['hostname']} ({', '.join(i['ips']) or '?'}) ══")
1093    print(f"   kernel {i['kernel']} · PID 1: {i['pid1']}")
1094    por_clase = {}
1095    for s in c["servicios"]:
1096        por_clase.setdefault(s["class"], []).append(s["name"])
1097    print("\n── servicios ──")
1098    for cl in ("vivo", "no-declarado", "declarado-muerto"):
1099        n = por_clase.get(cl, [])
1100        marca = "⚠ " if cl == "no-declarado" else "  "
1101        print(f"{marca}{cl:18} {len(n):3}  {' '.join(sorted(n)[:8])}{' …' if len(n) > 8 else ''}")
1102    if c.get("huerfanos"):
1103        print(f"   descartados (no son servicios: herramientas sueltas o HUÉRFANOS con ppid==1): "
1104              f"{len(c['huerfanos'])}  {' '.join(sorted(c['huerfanos'])[:8])}")
1105    if por_clase.get("no-declarado"):
1106        print("   ⚠ `no-declarado` = corre y NADIE sabe cómo levantarlo. Es el trabajo de la mudanza.")
1107    if c["dominios"]:
1108        print("\n── dominios ──")
1109        pd = {}
1110        for d in c["dominios"]:
1111            pd.setdefault(d["class"], []).append(d["domain"])
1112        for cl in ("vivo", "apunta-aca", "ya-mudado", "backend-caido", "fosil-sin-dns", "sin-sondear"):
1113            n = pd.get(cl, [])
1114            if n:
1115                print(f"  {cl:16} {len(n):3}  {' '.join(sorted(n)[:6])}{' …' if len(n) > 6 else ''}")
1116    fg = c.get("fuera_de_git", [])
1117    if fg:
1118        print("\n── fuera de git (config y secretos que no viajan con ningún repo) ──")
1119        for f in sorted(fg, key=lambda x: (x.get("owner", ""), x["path"])):
1120            duenio = f" [{f['owner']}]" if f.get("owner") else ""
1121            print(f"  {f['path']:42}{duenio:10} {f.get('size', '?'):>7}  {f.get('porque', '')[:52]}")
1122        ciegos = [f for f in fg if f.get("estado") == "sin_permiso"]
1123        if ciegos:
1124            # Un `ls` sin permiso se lee IGUAL que un directorio ausente. Decirlo es la diferencia
1125            # entre «no hay nada» y «no pude mirar», y sólo una de las dos se puede descartar.
1126            print(f"  ⚠ {len(ciegos)} ruta(s) que el censo NO PUDO MIRAR — no es lo mismo que "
1127                  f"ausentes (corré el censo como su dueño o con privilegio):")
1128            for f in ciegos:
1129                print(f"      {f['path']}")
1130    if c.get("datos_raiz"):
1131        print("\n── datos, por raíz ──")
1132        for d in c["datos_raiz"]:
1133            extra = f"  (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else ""
1134            print(f"  {d['path']:28} {d['size']:>7}{extra}")
1135    if c["datos"] and c["datos"] is not c.get("datos_raiz"):
1136        # ⚠ Se imprimen las 15 más grandes y SE DICE cuántas quedaron fuera. Cortar en silencio es
1137        # lo que hace que una lista parezca completa: el fichero las trae todas, y quien decide
1138        # tiene que saber que la pantalla no es el censo.
1139        print(f"\n── datos, cosa por cosa ({len(c['datos'])} entradas; el TOML las trae todas) ──")
1140        for d in c["datos"][:15]:
1141            print(f"  {d['path']:44} {d['size']:>6}  {d.get('descripcion','')[:64]}")
1142        if len(c["datos"]) > 15:
1143            print(f"  … y {len(c['datos']) - 15} más en el fichero")
1144    print()
1145
1146
1147def main():
1148    ap = argparse.ArgumentParser(description="Censo de una máquina para mudarla. Sólo lee.")
1149    ap.add_argument("--host", help="usuario@host a censar por SSH")
1150    ap.add_argument("--key", help="clave SSH")
1151    ap.add_argument("--local", action="store_true", help="censar esta máquina")
1152    ap.add_argument("--out", help="fichero TOML de salida (default: stdout sólo el resumen)")
1153    ap.add_argument("--probe", action="store_true",
1154                    help="sondear los dominios por DNS+HTTP. Por defecto SÓLO con --host: hacerlo "
1155                         "contra uno mismo puede disparar el fail2ban de la propia máquina")
1156    ap.add_argument("--probe-dns", action="store_true",
1157                    help="sondear SÓLO el DNS (sin HTTP): no toca al origen, así que es seguro "
1158                         "contra uno mismo. Dice qué dominios son fósiles y cuáles apuntan acá")
1159    ap.add_argument("--no-probe", action="store_true", help="no sondear dominios")
1160    ap.add_argument("--solo-totales", action="store_true",
1161                    help="medir los datos POR RAÍZ y no cosa por cosa (más rápido, pero deja "
1162                         "25 G de `/home` en una sola decisión)")
1163    ap.add_argument("--data-root", action="append", default=[],
1164                    help="raíz de datos a medir (repetible). Default: las habituales")
1165    a = ap.parse_args()
1166    if not a.host and not a.local:
1167        ap.error("hace falta --host o --local")
1168
1169    t = Objetivo(a.host, a.key)
1170    print("   censando… (no se ESCRIBE nada; las sondas de red sí dejan huella: ver --probe)",
1171          file=sys.stderr)
1172
1173    ident = identidad(t)
1174    vivos = procesos_vivos(t)
1175    decl = declarados(t)
1176    svc, huerfanos = clasificar(vivos, decl)
1177    inv = invocaciones(t, vivos)
1178    for e in svc:
1179        i = inv.get(e['name'], {})
1180        e['cmdline'] = i.get('cmdline', '')
1181        e['cwd'] = i.get('cwd', '')
1182        e['exe_real'] = i.get('exe', '')
1183        if i.get('exe_borrado'):
1184            e['exec_borrado'] = True
1185            e['pid_vivo'] = i.get('pid', 0)
1186            e['bytes_vivo'] = i.get('bytes_vivo', '')
1187            e['bytes_disco'] = i.get('bytes_disco', '')
1188        if i.get('usuario'):
1189            e['usuario'] = i['usuario']
1190    # `/proc/<pid>/exe` MANDA sobre `argv[0]`: es el binario de verdad y no lo puede reescribir el
1191    # proceso. `argv[0]` queda de respaldo para cuando la sonda no tuvo permiso.
1192    def _exe_de(e):
1193        return e.get('exe_real') or (
1194            (e.get('cmdline') or '').split(' ')[0] if (e.get('cmdline') or '').startswith('/') else '')
1195    ejes = [x for x in (_exe_de(e) for e in svc) if x]
1196    dn = duenos(t, ejes)
1197    raiz = os.geteuid() == 0 if a.local else None
1198    for e in svc:
1199        exe = _exe_de(e)
1200        e['exec'] = exe
1201        e['paquete'] = dn.get(exe, '')
1202        if not exe and e.get('pids'):
1203            # Decir POR QUÉ no se sabe. «Averigualo a mano» y «volvé a correr el censo con sudo» son
1204            # dos trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
1205            e['exec_motivo'] = ("el censo no corrió como root: `/proc/<pid>/exe` de un proceso de "
1206                                "otro usuario no se puede leer. Repetilo con sudo"
1207                                if raiz is False else
1208                                "el proceso reescribió su argv[0] y no se pudo leer /proc/<pid>/exe")
1209    pts = puertos(t)
1210    nombres = dominios(t)
1211    cont = contenido_web(t)
1212    sitios = sitios_web(t)
1213    sondear = (a.probe or a.probe_dns or bool(a.host)) and not a.no_probe
1214    http_ok = not a.probe_dns
1215    if nombres and not sondear:
1216        print(f"   ⚠ {len(nombres)} dominio(s) declarados, SIN sondear: censando en local, y pedirle",
1217              file=sys.stderr)
1218        print("     a la máquina sus propios dominios puede disparar su fail2ban y dejarla",
1219              file=sys.stderr)
1220        print("     incomunicada (pasó). Sondealos desde otra máquina, o forzá con --probe.",
1221              file=sys.stderr)
1222        doms = [{"domain": d, "dns": "", "http": "", "class": "sin-sondear"} for d in nombres]
1223    else:
1224        doms = []
1225        for i, d in enumerate(nombres):
1226            if i and http_ok:
1227                time.sleep(0.5)   # no parecer un escaneo ante el propio fail2ban del objetivo
1228            doms.append(sondear_dominio(d, set(ident["ips"]), http_ok))
1229    # ⚠ `/mnt/vvv` ENTRA (2026-09-16). No estaba, y ahí vive el trabajo: los repos de la persona, el
1230    # monorepo, el store y `work/sources`. El censo listaba 169 cosas y ninguna era el árbol donde
1231    # está todo — el agujero apareció cuando el usuario preguntó por `humanoid` y el censo sólo
1232    # conocía `/home/sergio/humanoid`, que son 200 M de `build/` viejo: el proyecto de verdad, con
1233    # su `.git`, estaba en `/mnt/vvv/humanoid`, fuera de toda raíz censada.
1234    # (`repos_git` sí lo miraba: una parte del censo conocía el árbol y la otra no.)
1235    raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store", "/mnt/vvv"]
1236    # Dos vistas y no una: el TOTAL por raíz es lo que dimensiona la mudanza (y lo que mira el
1237    # preflight), y el DETALLE por cosa es lo que se puede decidir. Dar sólo el total obliga a
1238    # decidir 25 G de una; dar sólo el detalle pierde el número que dice si entra en el destino.
1239    dat_raiz = datos(t, raices)
1240    dat = datos_detallados(t, raices, sitios, svc) if not a.solo_totales else dat_raiz
1241    # Después de `raices`, que es de dónde salen los repos a mirar. (Estaba antes y `raices` todavía
1242    # no existía: habría reventado con NameError en la primera corrida con datos.)
1243    repos = repos_git(t, raices + ["/mnt/vvv"], ident.get("ips", []), nombres)
1244    censo = {
1245        "fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1246        "identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts,
1247        "dominios": doms, "datos": dat, "datos_raiz": dat_raiz, "contenido": cont, "sitios": sitios, "repos": repos, "montajes": montajes(t),
1248        "fuera_de_git": fuera_de_git(t),
1249    }
1250    resumen(censo)
1251    if a.out:
1252        with open(a.out, "w") as f:
1253            f.write(emitir_toml(censo))
1254        print(f"   censo → {a.out}   (decidí `decision` en cada entrada y pasalo a la etapa 2)")
1255
1256
1257if __name__ == "__main__":
1258    main()