tawasuyu

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 1532Shell 196Rust 108Markdown 993686 Definitions

⎇ 74b1a0580cb18b3e6249ba9110ace800d1e04a44 scripts/mudanza/censar.py Python · 862 lines · 44.6 KB History Who touched what raw

planear: cruzar la config contra las decisiones — y los puertos se adjudicaban por NOMBRE f2bffd9e4 · 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 sitios_web(t):
326    """Los sitios declarados en la config, con su raíz y si esa raíz EXISTE.
327
328    ── POR QUÉ ESTO NO LO PODÍA DECIR EL DNS ───────────────────────────────────────────────────
329    La sonda DNS dice si un dominio resuelve; no si el servidor tiene algo que servirle. Un bloque
330    cuyo `root` apunta a un directorio que NO existe es un fósil que devuelve 502 — y eso se lee del
331    fichero: **sin red, sin pedirle una página al servidor, sin arriesgar su fail2ban**. Medido en
332    gioser: 3 sitios (`aura`, `sigma`, `kosmofono`) apuntan a raíces borradas, lo que explica los
333    502 que en su día hicieron que el censo se baneara a sí mismo al sondearlos por HTTP.
334
335    Y sirve para lo contrario: un directorio que la config SÍ referencia no es un huérfano. Sin esto
336    el censo marcó `/var/www/git-tawasuyu` como «nadie lo recuerda» cuando es un sitio vivo."""
337    import formatos
338    lectores, _ = formatos.cargar()
339    res = []
340    for patron, fmt in _CONFS:
341        if fmt not in lectores:
342            continue
343        ficheros = [patron]
344        if "*" in patron:
345            out, ok = t.run(f"ls -1 {patron} 2>/dev/null")
346            ficheros = out.split() if ok and out.strip() else []
347        for f in ficheros:
348            texto, ok = t.run(f"cat {f} 2>/dev/null")
349            if not ok or not texto.strip() or texto.startswith("(sonda falló"):
350                continue
351            try:
352                cfg = lectores[fmt][0](texto, f)
353            except Exception as e:
354                # Un parser que revienta NO puede tumbar el censo entero, pero tampoco callarse:
355                # sin esto uno cree que el servidor no declara sitios, que es lo contrario del dato.
356                res.append({"config": f, "formato": fmt, "nombres": "", "raiz": "",
357                            "raiz_existe": "error", "detalle": f"no se pudo leer: {e}"[:120]})
358                continue
359            for s in cfg.sitios:
360                # El `root` del sitio Y los de sus rutas anidadas: un `handle` con su propio `root`
361                # referencia disco igual, y no verlo es lo que hacía pasar por huérfano a un sitio vivo.
362                raices = [(s.raiz or "").rstrip("/")] + [
363                    a.valor.rstrip("/") for r in s.rutas for a in r.acciones if a.tipo == "archivos"]
364                # A DÓNDE PROXEA este sitio: la config declara de qué servicios depende, y eso es
365                # lo único que puede cazar «este dominio se muda y el servicio que lo sirve se mata».
366                prox = [a.valor for a in s.acciones if a.tipo == "proxy"] + [
367                    a.valor for r in s.rutas for a in r.acciones if a.tipo == "proxy"]
368                for raiz in dict.fromkeys(x for x in raices if x) or [""]:
369                    res.append({"config": f, "formato": fmt,
370                                "nombres": " ".join(s.nombres), "raiz": raiz,
371                                "proxies": " ".join(dict.fromkeys(prox)),
372                                "raiz_existe": "", "detalle": ""})
373    # Un solo comando para todas las raíces: una sonda por directorio sobre SSH es lentísima.
374    raices = sorted({r["raiz"] for r in res if r["raiz"]})
375    if raices:
376        guion = "; ".join(f'[ -d {shlex.quote(d)} ] && echo "SI {d}" || echo "NO {d}"' for d in raices)
377        out, _ = t.run(guion)
378        estado = {}
379        for l in out.splitlines():
380            p = l.split(None, 1)
381            if len(p) == 2:
382                estado[p[1]] = "si" if p[0] == "SI" else "no"
383        for r in res:
384            if r["raiz"]:
385                r["raiz_existe"] = estado.get(r["raiz"], "?")
386    return res
387
388
389def dominios(t):
390    """Los dominios que la máquina DICE servir, contrastados con DNS y con un GET real.
391
392    Las tres respuestas posibles importan y son distintas: sin DNS (nadie lo resuelve), con DNS pero
393    5xx (backend caído), y con DNS y 200 (vivo). Y hay una cuarta que sólo se ve mirando a DÓNDE
394    resuelve: un dominio que responde 200 desde OTRA IP ya está mudado, y su bloque de config acá
395    es un fósil."""
396    confs = ["/etc/caddy/Caddyfile", "/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/*",
397             "/etc/apache2/sites-enabled/*", "/etc/httpd/conf.d/*"]
398    out, _ = t.run("cat " + " ".join(confs) + " 2>/dev/null")
399    nombres = set()
400    for l in out.splitlines():
401        s = l.strip()
402        if s.startswith("#"):
403            continue
404        # caddy: «a.com, b.com {» · nginx/apache: «server_name a.com b.com;» / «ServerName a.com»
405        if s.endswith("{") and not s.startswith(("@", "}")):
406            cand = s[:-1]
407        elif s.lower().startswith(("server_name", "servername", "serveralias")):
408            cand = s.split(None, 1)[1].rstrip(";") if len(s.split(None, 1)) > 1 else ""
409        else:
410            continue
411        for d in re.split(r'[,\s]+', cand):
412            d = d.strip().rstrip(";")
413            if re.fullmatch(r'[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9-]+)+', d, re.I):
414                nombres.add(d.lower())
415    return sorted(nombres)
416
417
418def sondear_dominio(d, ips_maquina, http=True):
419    """DNS (+ HTTP opcional) desde donde corre el censo. Determinista, sin juicio.
420
421    ⚠⚠ **NO se hace contra la máquina que uno está censando desde ella misma.** «Sólo lee» era falso:
422    leer POR RED tiene efectos. Medido el 2026-09-11 — censar gioser en modo `--local` disparó 29
423    peticiones HTTPS a sus propios dominios, varios devuelven 502, y su `fail2ban` (jaula
424    `caddy-backend-down`) **baneó la propia IP de la máquina**. Resultado: gioser dejó de poder hablar
425    con su propio gitea y los `git push` empezaron a fallar con «Could not read from remote
426    repository». Un censo que deja la máquina incomunicada no es de sólo lectura.
427
428    Y además es metodológicamente mejor: lo que interesa es **qué ve el mundo**, no qué ve la máquina
429    de sí misma. Por eso la sonda se hace desde donde corre el censo —el operador o la caja nueva— y
430    se SALTA cuando se censa en local, salvo `--probe` explícito.
431
432    ── PERO EL DNS NO TOCA AL ORIGEN ───────────────────────────────────────────────────────────
433    Lo que dispara el fail2ban es el HTTP, no la resolución: `getent` le pregunta al DNS, no al
434    servidor. Con `--probe-dns` se sondea SÓLO el nombre, y eso ya contesta la pregunta que más
435    pesa en una mudanza — **cuáles de estos dominios siguen apuntando acá y cuáles son fósiles**.
436    Lo que el DNS solo NO puede decir es si el backend contesta, así que esos dominios quedan en
437    `apunta-aca`, que es una clase propia y no `vivo`: decir «vivo» sin haber pedido una página
438    sería exactamente la respuesta falsa con forma de respuesta que este censo existe para evitar."""
439    ip = ""
440    try:
441        r = subprocess.run(["getent", "ahostsv4", d], capture_output=True, text=True, timeout=10)
442        if r.returncode == 0 and r.stdout.strip():
443            ip = r.stdout.split()[0]
444    except Exception:
445        pass
446    code = ""
447    if ip and http:
448        try:
449            r = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}",
450                                "--max-time", "8", f"https://{d}/"], capture_output=True, text=True, timeout=20)
451            code = r.stdout.strip() or "000"
452        except Exception:
453            pass
454    if not ip:
455        clase = "fosil-sin-dns"
456    elif ip not in ips_maquina:
457        clase = "ya-mudado"          # resuelve a otra máquina: el bloque de acá es fósil
458    elif not http:
459        clase = "apunta-aca"         # resuelve acá; si SIRVE no se preguntó, y no se inventa
460    elif code.startswith(("2", "3")):
461        clase = "vivo"
462    else:
463        clase = "backend-caido"
464    return {"domain": d, "dns": ip, "http": code, "class": clase}
465
466
467def datos(t, raices):
468    """Qué pesa y dónde. Sin esto no se puede dimensionar la mudanza — y `du` sobre hardlinks MIENTE
469    para dimensionar una COPIA (medido: 60 G por `du`, 85 G al copiar sin `-H`), así que se reportan
470    los dos números."""
471    res = []
472    for r in raices:
473        out, ok = t.run(f"du -sh {shlex.quote(r)} 2>/dev/null | cut -f1; "
474                        f"du -sh --count-links {shlex.quote(r)} 2>/dev/null | cut -f1")
475        ls = out.splitlines()
476        if ok and ls:
477            res.append({"path": r, "size": ls[0],
478                        "size_copy": ls[1] if len(ls) > 1 else ls[0]})
479    return res
480
481
482def montajes(t):
483    out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ")
484    res = []
485    for l in out.splitlines():
486        f = l.split()
487        if len(f) >= 6:
488            res.append({"dev": f[0], "size": f[1], "used": f[2], "avail": f[3], "mount": f[-1]})
489    return res
490
491
492def fuera_de_git(t):
493    """Lo que NO viaja con el repo y sin lo cual el servidor nuevo no funciona. Se reporta la
494    EXISTENCIA, nunca el contenido: un censo no copia secretos."""
495    cands = ["~/.ssh", "/root/.ssh", "~/.config/hammer", "/root/.config/hammer",
496             "/etc/caddy", "/etc/nginx", "/etc/letsencrypt", "/var/lib/gitea",
497             "/etc/arje/cards.d", "/ente"]
498    res = []
499    for c in cands:
500        out, ok = t.run(f"ls -d {c} >/dev/null 2>&1 && ls -1 {c} 2>/dev/null | head -20")
501        if ok:
502            res.append({"path": c, "entries": out.split() if out else []})
503    out, _ = t.run("crontab -l 2>/dev/null | grep -vE '^#|^$' | wc -l")
504    if out.isdigit() and int(out) > 0:
505        res.append({"path": "crontab", "entries": [f"{out} líneas"]})
506    return res
507
508
509def contenido_web(t, raices=("/var/www", "/srv")):
510    """Qué hay en las raíces de contenido web, con su tamaño. Un nivel, sin juicio.
511
512    ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────
513    La regla 2 del plan dice que lo que muere se nombra «por su nombre Y CON SU TAMAÑO». Los
514    servicios y los datos ya lo cumplían; los DOMINIOS no — se listaban pelados, y un dominio fósil
515    no pesa nada por sí mismo: pesa lo que dejó en disco. `terapeuta.ec` no resuelve en ningún DNS y
516    tiene 279 M en `/var/www/terapeuta`. Que eso se supiera dependía de que alguien se acordara.
517
518    ⚠ **No se afirma la correspondencia, se OFRECE.** El directorio se llama `terapeuta` y el
519    dominio `terapeuta.ec`: no coinciden. Una regla difusa que empareje «casi» acierta la mayoría de
520    las veces y el resto de las veces manda a borrar lo que no era. Se listan los candidatos con su
521    tamaño y decide el humano — y se listan TAMBIÉN los directorios que no se parecen a ningún
522    dominio, que son los que nadie recuerda (`gioser.bak.20260501`, `ogisoer`)."""
523    res = []
524    for raiz in raices:
525        out, ok = t.run(f"du -sh {raiz}/* 2>/dev/null | sort -rh | head -40")
526        if not ok or not out.strip():
527            continue
528        for l in out.splitlines():
529            p = l.split(None, 1)
530            if len(p) == 2 and p[1].startswith("/"):
531                res.append({"path": p[1], "size": p[0], "raiz": raiz})
532    return res
533
534
535def candidatos_de(dominio, contenido):
536    """Directorios que PODRÍAN ser de este dominio. Por etiqueta principal, no por nombre completo."""
537    d = dominio.lower()
538    etiquetas = [x for x in d.split(".") if x not in ("www", "com", "net", "org", "ec", "io", "dev")]
539    principal = etiquetas[-1] if etiquetas else d
540    out = []
541    for c in contenido:
542        base = os.path.basename(c["path"]).lower()
543        if principal and (principal in base or base.split(".")[0] in etiquetas):
544            out.append(c)
545    return out
546
547
548# ── clasificación ───────────────────────────────────────────────────────────────────────────────
549
550# Nombres que NO son servicios: son el andamiaje del sistema o hijos de un servicio. Contarlos como
551# `no-declarado` es ruido, y un censo con hallazgos falsos se ignora entero.
552NO_SERVICIOS = {
553    "agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo",
554    "bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl",
555    # Restos de tubería reparentados a PID 1 cuando su padre muere. Medido: `head -15` figuraba como
556    # servicio `no-declarado` con ppid==1. No se esconden —salen en `descartados`, porque un huérfano
557    # ES un hallazgo— pero no son servicios y contarlos como tales es el ruido que mata un censo.
558    "head", "tr", "cut", "grep", "wc", "find", "xargs", "tee", "sort", "uniq", "env", "nohup",
559    "timeout", "flock", "tar", "gzip", "zstd", "dd", "md5sum", "sha256sum", "less", "more",
560}
561
562
563def normalizar(n):
564    """Un mismo servicio se llama distinto en cada sitio: OpenRC dice `act-runner`, `ps` dice
565    `act_runner`, systemd dice `act-runner.service`, y los getty por tty son `agetty.tty1`,
566    `agetty-tty2`… Sin normalizar, el MISMO proceso sale a la vez como `no-declarado` y como
567    `declarado-muerto`, que son las dos clases que más importan. Medido en gioser: pasaba con
568    `act_runner`."""
569    n = n.strip().lower()
570    for suf in (".service", ".socket", ".timer", ".target"):
571        if n.endswith(suf):
572            n = n[: -len(suf)]
573    n = n.replace("_", "-")
574    n = re.sub(r'[.\-]tty\d+$', '', n)       # agetty.tty1 / agetty-tty2 → agetty
575    n = re.sub(r'\d+$', '', n) if re.fullmatch(r'[a-z-]+\d+', n) else n
576    return n
577
578
579def emparenta(vivo, declarado):
580    """¿Son el mismo servicio con distinto nombre? Los desajustes son sistemáticos, no caprichosos:
581    el DEMONIO y la UNIDAD se llaman distinto. Medidos en gioser: `crond`↔`cronie`,
582    `dbus-daemon`↔`dbus`, `fail2ban-server`↔`fail2ban`, `elogind-daemon`↔`elogind`.
583
584    Sin esto, cada uno de ésos aparece a la vez como `no-declarado` (falso: sí está declarado) y como
585    `declarado-muerto` (falso: sí está corriendo) — las dos clases que más importan, las dos mal."""
586    if vivo == declarado:
587        return True
588    for suf in ("-daemon", "-server", "-bin"):
589        if vivo.endswith(suf) and vivo[: -len(suf)] == declarado:
590            return True
591    # `crond` → `cron`, prefijo de `cronie`. `sshd` → `ssh`. Sólo quitando UNA `d` final y exigiendo
592    # prefijo: quitar letras a ciegas emparenta cosas que no tienen nada que ver.
593    if len(vivo) > 4 and vivo.endswith("d") and declarado.startswith(vivo[:-1]):
594        return True
595    return False
596
597
598def es_nombre_de_servicio(n):
599    """Descarta lo que claramente no es un servicio: versiones (`2.1.240`), rutas, vacíos. Salieron
600    de parsear `rc-status -a`, cuya salida incluye líneas que no son unidades."""
601    return bool(re.fullmatch(r'[a-z][a-z0-9@._+-]{1,40}', n)) and not re.fullmatch(r'[\d.]+', n)
602
603
604def clasificar(vivos, decl):
605    """Las tres clases. `no-declarado` es la importante: corre y nadie sabe cómo levantarlo."""
606    # índice normalizado: nombre-canónico → fuentes que lo declaran
607    idx = {}
608    for fuente, nombres in decl.items():
609        for n in nombres:
610            if es_nombre_de_servicio(n.lower()):
611                idx.setdefault(normalizar(n), set()).add(fuente)
612
613    out, vistos, descartados = [], set(), []
614    for nombre, pids in sorted(vivos.items()):
615        canon = normalizar(nombre)
616        if canon in NO_SERVICIOS or not es_nombre_de_servicio(canon):
617            # No se esconde: un proceso con ppid==1 que no es un servicio suele ser un HUÉRFANO, y
618            # eso es un hallazgo (procesos fugados que retienen locks, por ejemplo).
619            descartados.append(nombre)
620            continue
621        fuentes = sorted(idx.get(canon, set()))
622        if not fuentes:  # segunda pasada: demonio vs unidad
623            emparentados = [f for d, fs in idx.items() if emparenta(canon, d) for f in fs]
624            fuentes = sorted(set(emparentados))
625            if fuentes:
626                vistos.update(d for d in idx if emparenta(canon, d))
627        vistos.add(canon)
628        out.append({"name": nombre, "pids": pids,
629                    "class": "vivo" if fuentes else "no-declarado",
630                    "declared_in": fuentes})
631    for canon in sorted(set(idx) - vistos):
632        if canon in NO_SERVICIOS:
633            continue
634        out.append({"name": canon, "pids": [],
635                    "class": "declarado-muerto",
636                    "declared_in": sorted(idx[canon])})
637    return out, descartados
638
639
640# ── salida ──────────────────────────────────────────────────────────────────────────────────────
641
642def toml_escape(s):
643    return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
644
645
646def emitir_toml(c):
647    L = []
648    L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo")
649    L.append("# para DECIDIR: la etapa siguiente lee `decision` de cada entrada.")
650    L.append(f"schema = \"takana-censo/1\"")
651    L.append(f"fecha = {toml_escape(c['fecha'])}")
652    L.append("")
653    L.append("[maquina]")
654    for k, v in c["identidad"].items():
655        L.append(f"{k} = {toml_escape(v) if isinstance(v, str) else json.dumps(v)}")
656    L.append("")
657    for m in c["montajes"]:
658        L.append("[[montaje]]")
659        for k, v in m.items():
660            L.append(f"{k} = {toml_escape(v)}")
661        L.append("")
662    for s in c["servicios"]:
663        L.append("[[servicio]]")
664        L.append(f"name = {toml_escape(s['name'])}")
665        L.append(f"class = {toml_escape(s['class'])}")
666        L.append(f"declared_in = {json.dumps(s['declared_in'])}")
667        L.append(f"pids = {json.dumps(s['pids'])}")
668        # ⚠ Por PID, no por nombre. Emparejar `proc.startswith(name[:15])` le daba el puerto 7378
669        # —que escucha `shuma-gateway`, comprobado con `ss -lntp`— al `shuma` DECLARADO-MUERTO, que
670        # por definición no puede estar escuchando nada. El puerto es la señal más fuerte de que algo
671        # sirve, así que colgárselo al servicio equivocado envenena todo lo que se derive de él.
672        _pids = set(s.get("pids") or [])
673        _mios = [p["port"] for p in c["puertos"] if p.get("pid") and p["pid"] in _pids]
674        if not _mios and _pids:
675            # Sin pid en la salida de `ss` (sin permisos, o `netstat` viejo) se cae al nombre, pero
676            # sólo para servicios VIVOS: un declarado-muerto nunca escucha.
677            _mios = [p["port"] for p in c["puertos"]
678                     if not p.get("pid") and p["proc"].startswith(s["name"][:15])]
679        elif not _pids:
680            _mios = []
681        L.append(f"ports = {json.dumps(sorted(set(_mios)))}")
682        L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}")
683        L.append(f"cwd = {toml_escape(s.get('cwd',''))}")
684        L.append(f"exec = {toml_escape(s.get('exec',''))}")
685        if s.get('usuario'):
686            L.append(f"usuario = {toml_escape(s['usuario'])}")
687        if s.get('exec_borrado'):
688            L.append("exec_borrado = true")
689            L.append(f"pid_vivo = {int(s.get('pid_vivo') or 0)}")
690            L.append(f"bytes_vivo = {toml_escape(s.get('bytes_vivo',''))}")
691            L.append(f"bytes_disco = {toml_escape(s.get('bytes_disco',''))}")
692        if s.get('exec_motivo'):
693            L.append(f"exec_motivo = {toml_escape(s['exec_motivo'])}")
694        L.append(f"paquete = {toml_escape(s.get('paquete',''))}")
695        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
696        L.append("")
697    for x in c.get("sitios", []):
698        L.append("[[sitio]]")
699        for k, v in x.items():
700            L.append(f"{k} = {toml_escape(v)}")
701        L.append("")
702    for x in c.get("contenido", []):
703        L.append("[[contenido]]")
704        for k, v in x.items():
705            L.append(f"{k} = {toml_escape(v)}")
706        L.append("")
707    for d in c["dominios"]:
708        L.append("[[dominio]]")
709        for k, v in d.items():
710            L.append(f"{k} = {toml_escape(v)}")
711        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
712        L.append("")
713    for d in c["datos"]:
714        L.append("[[datos]]")
715        for k, v in d.items():
716            L.append(f"{k} = {toml_escape(v)}")
717        # ⚠ `destino` existe porque la ruta de origen NO tiene por qué caber en la misma ruta del
718        # destino. Medido contra la caja takana: `/` tiene 3,5 G libres y los datos de gioser son
719        # 33,6 G — el sitio está en `/work` (66 G) y en `/store` (19 G). Sin este campo el plan
720        # copiaba ruta→misma ruta y la mudanza no entraba, con el detalle de que el fallo aparece a
721        # mitad de un rsync de 22 G.
722        L.append('destino = ""   # vacío = la MISMA ruta en el destino. Ej: "/work/home"')
723        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
724        L.append("")
725    for f in c["fuera_de_git"]:
726        L.append("[[fuera_de_git]]")
727        L.append(f"path = {toml_escape(f['path'])}")
728        L.append(f"entries = {json.dumps(f['entries'])}")
729        L.append("")
730    return "\n".join(L) + "\n"
731
732
733def resumen(c):
734    i = c["identidad"]
735    print(f"\n══ CENSO de {i['hostname']} ({', '.join(i['ips']) or '?'}) ══")
736    print(f"   kernel {i['kernel']} · PID 1: {i['pid1']}")
737    por_clase = {}
738    for s in c["servicios"]:
739        por_clase.setdefault(s["class"], []).append(s["name"])
740    print("\n── servicios ──")
741    for cl in ("vivo", "no-declarado", "declarado-muerto"):
742        n = por_clase.get(cl, [])
743        marca = "⚠ " if cl == "no-declarado" else "  "
744        print(f"{marca}{cl:18} {len(n):3}  {' '.join(sorted(n)[:8])}{' …' if len(n) > 8 else ''}")
745    if c.get("huerfanos"):
746        print(f"   descartados (no son servicios: herramientas sueltas o HUÉRFANOS con ppid==1): "
747              f"{len(c['huerfanos'])}  {' '.join(sorted(c['huerfanos'])[:8])}")
748    if por_clase.get("no-declarado"):
749        print("   ⚠ `no-declarado` = corre y NADIE sabe cómo levantarlo. Es el trabajo de la mudanza.")
750    if c["dominios"]:
751        print("\n── dominios ──")
752        pd = {}
753        for d in c["dominios"]:
754            pd.setdefault(d["class"], []).append(d["domain"])
755        for cl in ("vivo", "apunta-aca", "ya-mudado", "backend-caido", "fosil-sin-dns", "sin-sondear"):
756            n = pd.get(cl, [])
757            if n:
758                print(f"  {cl:16} {len(n):3}  {' '.join(sorted(n)[:6])}{' …' if len(n) > 6 else ''}")
759    if c["datos"]:
760        print("\n── datos ──")
761        for d in c["datos"]:
762            extra = f"  (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else ""
763            print(f"  {d['path']:28} {d['size']:>7}{extra}")
764    print()
765
766
767def main():
768    ap = argparse.ArgumentParser(description="Censo de una máquina para mudarla. Sólo lee.")
769    ap.add_argument("--host", help="usuario@host a censar por SSH")
770    ap.add_argument("--key", help="clave SSH")
771    ap.add_argument("--local", action="store_true", help="censar esta máquina")
772    ap.add_argument("--out", help="fichero TOML de salida (default: stdout sólo el resumen)")
773    ap.add_argument("--probe", action="store_true",
774                    help="sondear los dominios por DNS+HTTP. Por defecto SÓLO con --host: hacerlo "
775                         "contra uno mismo puede disparar el fail2ban de la propia máquina")
776    ap.add_argument("--probe-dns", action="store_true",
777                    help="sondear SÓLO el DNS (sin HTTP): no toca al origen, así que es seguro "
778                         "contra uno mismo. Dice qué dominios son fósiles y cuáles apuntan acá")
779    ap.add_argument("--no-probe", action="store_true", help="no sondear dominios")
780    ap.add_argument("--data-root", action="append", default=[],
781                    help="raíz de datos a medir (repetible). Default: las habituales")
782    a = ap.parse_args()
783    if not a.host and not a.local:
784        ap.error("hace falta --host o --local")
785
786    t = Objetivo(a.host, a.key)
787    print("   censando… (no se ESCRIBE nada; las sondas de red sí dejan huella: ver --probe)",
788          file=sys.stderr)
789
790    ident = identidad(t)
791    vivos = procesos_vivos(t)
792    decl = declarados(t)
793    svc, huerfanos = clasificar(vivos, decl)
794    inv = invocaciones(t, vivos)
795    for e in svc:
796        i = inv.get(e['name'], {})
797        e['cmdline'] = i.get('cmdline', '')
798        e['cwd'] = i.get('cwd', '')
799        e['exe_real'] = i.get('exe', '')
800        if i.get('exe_borrado'):
801            e['exec_borrado'] = True
802            e['pid_vivo'] = i.get('pid', 0)
803            e['bytes_vivo'] = i.get('bytes_vivo', '')
804            e['bytes_disco'] = i.get('bytes_disco', '')
805        if i.get('usuario'):
806            e['usuario'] = i['usuario']
807    # `/proc/<pid>/exe` MANDA sobre `argv[0]`: es el binario de verdad y no lo puede reescribir el
808    # proceso. `argv[0]` queda de respaldo para cuando la sonda no tuvo permiso.
809    def _exe_de(e):
810        return e.get('exe_real') or (
811            (e.get('cmdline') or '').split(' ')[0] if (e.get('cmdline') or '').startswith('/') else '')
812    ejes = [x for x in (_exe_de(e) for e in svc) if x]
813    dn = duenos(t, ejes)
814    raiz = os.geteuid() == 0 if a.local else None
815    for e in svc:
816        exe = _exe_de(e)
817        e['exec'] = exe
818        e['paquete'] = dn.get(exe, '')
819        if not exe and e.get('pids'):
820            # Decir POR QUÉ no se sabe. «Averigualo a mano» y «volvé a correr el censo con sudo» son
821            # dos trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
822            e['exec_motivo'] = ("el censo no corrió como root: `/proc/<pid>/exe` de un proceso de "
823                                "otro usuario no se puede leer. Repetilo con sudo"
824                                if raiz is False else
825                                "el proceso reescribió su argv[0] y no se pudo leer /proc/<pid>/exe")
826    pts = puertos(t)
827    nombres = dominios(t)
828    cont = contenido_web(t)
829    sitios = sitios_web(t)
830    sondear = (a.probe or a.probe_dns or bool(a.host)) and not a.no_probe
831    http_ok = not a.probe_dns
832    if nombres and not sondear:
833        print(f"   ⚠ {len(nombres)} dominio(s) declarados, SIN sondear: censando en local, y pedirle",
834              file=sys.stderr)
835        print("     a la máquina sus propios dominios puede disparar su fail2ban y dejarla",
836              file=sys.stderr)
837        print("     incomunicada (pasó). Sondealos desde otra máquina, o forzá con --probe.",
838              file=sys.stderr)
839        doms = [{"domain": d, "dns": "", "http": "", "class": "sin-sondear"} for d in nombres]
840    else:
841        doms = []
842        for i, d in enumerate(nombres):
843            if i and http_ok:
844                time.sleep(0.5)   # no parecer un escaneo ante el propio fail2ban del objetivo
845            doms.append(sondear_dominio(d, set(ident["ips"]), http_ok))
846    raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store"]
847    dat = datos(t, raices)
848    censo = {
849        "fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
850        "identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts,
851        "dominios": doms, "datos": dat, "contenido": cont, "sitios": sitios, "montajes": montajes(t),
852        "fuera_de_git": fuera_de_git(t),
853    }
854    resumen(censo)
855    if a.out:
856        with open(a.out, "w") as f:
857            f.write(emitir_toml(censo))
858        print(f"   censo → {a.out}   (decidí `decision` en cada entrada y pasalo a la etapa 2)")
859
860
861if __name__ == "__main__":
862    main()