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 1465Shell 173Rust 96Markdown 943317 Sutichaykuna

⎇ 2fa89ce3fa233fdcc4be348d011986f78b03b5de scripts/mudanza/censar.py Python · 684 siq'ikuna · 34.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

censar: tres nombres equivocados que llegaban al plan, y los 7 «binario desconocido» eran permisos 2fa89ce3f · 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        for v in ("cmd", "cwd", "exe"):
105            if locals()[v].startswith("(sonda falló"):
106                cmd, cwd, exe = (("" if v == "cmd" else cmd), ("" if v == "cwd" else cwd),
107                                 ("" if v == "exe" else exe))
108        sup = SUPERVISADOS.get(pid)
109        if sup:
110            cmd, exe = sup["cmdline"], sup["exec"]
111        if cmd or cwd or exe:
112            out[nombre] = {"cmdline": cmd, "cwd": cwd,
113                           "exe": exe if exe.startswith("/") else "",
114                           "usuario": (sup or {}).get("usuario", "")}
115    return out
116
117
118def duenos(t, ejecutables):
119    """¿Quién PROVEE el binario de cada servicio? Determinista, preguntándole al gestor de paquetes
120    del origen.
121
122    Es lo que convierte «instalá el paquete» —que es lo que uno ya sabía— en una instrucción: o el
123    binario lo trae un paquete con nombre, o NO lo trae nadie y entonces es un compilado suelto que
124    hay que llevar o reconstruir. La diferencia decide el trabajo, y no se adivina.
125
126    Una consulta por lote, no una por servicio: son decenas de servicios y cada `ssh` cuesta."""
127    if not ejecutables:
128        return {}
129    lista = " ".join(shlex.quote(e) for e in sorted(set(ejecutables)))
130    # Se prueba el gestor que exista. `2>/dev/null || echo -` ⇒ un fichero sin dueño no aborta el lote.
131    guion = (
132        'for f in %s; do '
133        '  if command -v pacman >/dev/null 2>&1; then o=$(pacman -Qoq "$f" 2>/dev/null); '
134        '  elif command -v dpkg >/dev/null 2>&1; then o=$(dpkg -S "$f" 2>/dev/null | cut -d: -f1); '
135        '  elif command -v apk >/dev/null 2>&1; then o=$(apk info -W "$f" 2>/dev/null | tail -1 '
136        '       | sed "s/.* is owned by //;s/-[0-9].*//"); '
137        '  elif command -v rpm >/dev/null 2>&1; then o=$(rpm -qf "$f" 2>/dev/null); '
138        '  else o=""; fi; '
139        '  echo "$f|${o:-}"; done'
140    ) % lista
141    out, _ = t.run(guion)
142    res = {}
143    for l in out.splitlines():
144        if "|" in l:
145            f, o = l.split("|", 1)
146            res[f.strip()] = o.strip()
147    return res
148
149
150# Envoltorios que NO son el servicio: el nombre de verdad está en sus argumentos. Sin desenvolverlos,
151# los CINCO `supervise-daemon` de gioser se juntaban en UNA entrada llamada `supervise-daemo` — cinco
152# servicios distintos (shuma-daemon, dbus, metalog…) colapsados en una tarjeta con el nombre del
153# envoltorio. Un servicio de más es ruido; cinco servicios fundidos en uno es un plan que no arranca.
154SUPERVISORES = {"supervise-daemon", "start-stop-daemon", "s6-supervise", "runsv", "daemonize"}
155_COMM_SUP = {s[:15] for s in SUPERVISORES}
156
157# pid → lo que el supervisor manda de verdad. Se llena al listar los procesos y se consulta al leer
158# `/proc`: el pid con ppid==1 es el SUPERVISOR, así que su `cmdline` y su `exe` describen al
159# envoltorio, no al servicio.
160SUPERVISADOS = {}
161
162
163def _mandado_por_supervisor(args):
164    """El comando REAL que hay detrás de `supervise-daemon`, y con qué usuario corre.
165
166    El formato es `supervise-daemon <servicio> --start [opciones] <ejecutable> -- [argumentos]`, así
167    que el ejecutable es el ÚLTIMO token antes del `--` suelto. Comprobado contra los cinco de gioser
168    (dbus, dhcpcd, metalog, squid, shuma-daemon).
169
170    ⚠ Sin esto, el censo guardaba como `cmdline` la línea del SUPERVISOR — y una tarjeta de arje
171    hecha con eso arrancaría `supervise-daemon` DENTRO de arje, que ya supervisa. Y el `exec` salía
172    `/usr/bin/supervise-daemon`, o sea que `squid` figuraba como provisto por el paquete `openrc`.
173
174    ⚠⚠ Y se rescata el `--user`: `shuma-daemon` corre como `sergio`. Perder ese dato al desenvolver
175    sería la misma elisión que el escritor de arje reporta como SIN-TRADUCIR — un servicio que en el
176    origen corría sin privilegios terminaría de root en el destino, y nadie se enteraría."""
177    p = args.split()
178    if "--start" not in p:
179        return "", "", ""
180    resto = p[p.index("--start") + 1:]
181    usuario = ""
182    for i, x in enumerate(resto):
183        if x in ("--user", "-u") and i + 1 < len(resto):
184            usuario = resto[i + 1]
185            break
186    corte = resto.index("--") if "--" in resto else len(resto)
187    izq, der = resto[:corte], resto[corte + 1:]
188    if not izq or not izq[-1].startswith("/"):
189        return "", "", usuario
190    return izq[-1], " ".join([izq[-1]] + der), usuario
191
192
193def _desenvolver(comm, args):
194    """(nombre_real, args_reales) para un proceso que puede ser un envoltorio."""
195    p = args.split()
196    if not p:
197        return comm, args
198    base = os.path.basename(p[0])
199    if base not in SUPERVISORES and comm not in {s[:15] for s in SUPERVISORES}:
200        return comm, args
201    if base in ("supervise-daemon", "s6-supervise", "runsv") and len(p) > 1:
202        # `supervise-daemon <servicio> --start …` · `runsv /etc/service/<servicio>`
203        return os.path.basename(p[1].rstrip("/")), args
204    for i, x in enumerate(p):                      # start-stop-daemon
205        if x in ("--name", "-n") and i + 1 < len(p):
206            return p[i + 1], args
207        if x in ("--exec", "-x", "--startas", "-a") and i + 1 < len(p):
208            return os.path.basename(p[i + 1]), args
209    return comm, args
210
211
212def _destruncar(comm, args):
213    """`comm` viene del kernel y está capado a 15 caracteres: `willay-crosscheck` llega como
214    `willay-crossche`. Ese nombre truncado no es cosmético — es el que el plan mete en
215    `--in /etc/init.d/<nombre>` (ruta que no existe) y el que va de `label` en la tarjeta de arje.
216    La línea de comando sí trae el nombre entero, así que se usa ESA cuando confirma al truncado."""
217    if len(comm) < 15:
218        return comm
219    p = args.split()
220    if p:
221        base = os.path.basename(p[0])
222        if base.startswith(comm):
223            return base
224    return comm
225
226
227def procesos_vivos(t):
228    """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración."""
229    out, _ = t.run("ps -eo pid=,ppid=,comm=,args= 2>/dev/null | awk '$2==1 && $1!=1'")
230    vivos = {}
231    for l in out.splitlines():
232        parts = l.split(None, 3)
233        if len(parts) < 3 or not parts[0].isdigit():
234            continue
235        pid, comm, args = int(parts[0]), parts[2], (parts[3] if len(parts) > 3 else "")
236        nombre, args = _desenvolver(comm, args)
237        nombre = _destruncar(nombre, args)
238        exe, linea, usuario = _mandado_por_supervisor(args) if comm[:15] in _COMM_SUP else ("", "", "")
239        if exe:
240            SUPERVISADOS[pid] = {"exec": exe, "cmdline": linea, "usuario": usuario}
241        vivos.setdefault(nombre, []).append(pid)
242    # los kernel threads no son servicios
243    return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq",
244                                                                "migration", "rcu_", "irq/", "idle_"))}
245
246
247def puertos(t):
248    """Qué SIRVE esta máquina, y quién. Un servicio sin puerto no se muda igual que uno con puerto."""
249    out, ok = t.run("ss -lntpH 2>/dev/null || netstat -lntp 2>/dev/null | tail -n +3")
250    res = []
251    for l in out.splitlines():
252        m = re.search(r'(\S+):(\d+)\s', l)
253        proc = re.search(r'\("([^"]+)"|/(\S+)\s*$', l)
254        if m:
255            res.append({"addr": m.group(1), "port": int(m.group(2)),
256                        "proc": (proc.group(1) or proc.group(2)) if proc else "?"})
257    # dedup por (puerto, proc)
258    vistos, uniq = set(), []
259    for r in sorted(res, key=lambda x: x["port"]):
260        k = (r["port"], r["proc"])
261        if k not in vistos:
262            vistos.add(k); uniq.append(r)
263    return uniq
264
265
266def declarados(t):
267    """Lo que el init DICE que hay. Se compara contra lo vivo — no se cree."""
268    fuentes = {}
269    # `rc-status -a` lista TODO lo disponible en el sistema, no lo que esta máquina usa: en gioser
270    # daba 100 «declarado-muerto» que no significan nada. `rc-update show` da lo ENCENDIDO por
271    # runlevel, que es lo que de verdad se declara.
272    out, ok = t.run("rc-update show 2>/dev/null | awk '{print $1}' | grep -E '^[a-z]'")
273    if ok and out:
274        fuentes["openrc"] = out.split()
275    out, ok = t.run("systemctl list-units --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}'")
276    if ok and out:
277        fuentes["systemd"] = [s.replace(".service", "") for s in out.split()]
278    out, ok = t.run("ls /etc/arje/cards.d/ 2>/dev/null")
279    if ok and out:
280        fuentes["arje-cards"] = [s.replace(".json", "") for s in out.split()]
281    out, ok = t.run("python3 -c \"import json;print(' '.join(n['label'] for n in "
282                    "json.load(open('/ente/seed.card.json'))['genesis']))\" 2>/dev/null")
283    if ok and out:
284        fuentes["arje-seed"] = out.split()
285    out, ok = t.run("ls /etc/service /run/runit/service 2>/dev/null")
286    if ok and out:
287        fuentes["runit"] = out.split()
288    return fuentes
289
290
291def dominios(t):
292    """Los dominios que la máquina DICE servir, contrastados con DNS y con un GET real.
293
294    Las tres respuestas posibles importan y son distintas: sin DNS (nadie lo resuelve), con DNS pero
295    5xx (backend caído), y con DNS y 200 (vivo). Y hay una cuarta que sólo se ve mirando a DÓNDE
296    resuelve: un dominio que responde 200 desde OTRA IP ya está mudado, y su bloque de config acá
297    es un fósil."""
298    confs = ["/etc/caddy/Caddyfile", "/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/*",
299             "/etc/apache2/sites-enabled/*", "/etc/httpd/conf.d/*"]
300    out, _ = t.run("cat " + " ".join(confs) + " 2>/dev/null")
301    nombres = set()
302    for l in out.splitlines():
303        s = l.strip()
304        if s.startswith("#"):
305            continue
306        # caddy: «a.com, b.com {» · nginx/apache: «server_name a.com b.com;» / «ServerName a.com»
307        if s.endswith("{") and not s.startswith(("@", "}")):
308            cand = s[:-1]
309        elif s.lower().startswith(("server_name", "servername", "serveralias")):
310            cand = s.split(None, 1)[1].rstrip(";") if len(s.split(None, 1)) > 1 else ""
311        else:
312            continue
313        for d in re.split(r'[,\s]+', cand):
314            d = d.strip().rstrip(";")
315            if re.fullmatch(r'[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9-]+)+', d, re.I):
316                nombres.add(d.lower())
317    return sorted(nombres)
318
319
320def sondear_dominio(d, ips_maquina, http=True):
321    """DNS (+ HTTP opcional) desde donde corre el censo. Determinista, sin juicio.
322
323    ⚠⚠ **NO se hace contra la máquina que uno está censando desde ella misma.** «Sólo lee» era falso:
324    leer POR RED tiene efectos. Medido el 2026-09-11 — censar gioser en modo `--local` disparó 29
325    peticiones HTTPS a sus propios dominios, varios devuelven 502, y su `fail2ban` (jaula
326    `caddy-backend-down`) **baneó la propia IP de la máquina**. Resultado: gioser dejó de poder hablar
327    con su propio gitea y los `git push` empezaron a fallar con «Could not read from remote
328    repository». Un censo que deja la máquina incomunicada no es de sólo lectura.
329
330    Y además es metodológicamente mejor: lo que interesa es **qué ve el mundo**, no qué ve la máquina
331    de sí misma. Por eso la sonda se hace desde donde corre el censo —el operador o la caja nueva— y
332    se SALTA cuando se censa en local, salvo `--probe` explícito.
333
334    ── PERO EL DNS NO TOCA AL ORIGEN ───────────────────────────────────────────────────────────
335    Lo que dispara el fail2ban es el HTTP, no la resolución: `getent` le pregunta al DNS, no al
336    servidor. Con `--probe-dns` se sondea SÓLO el nombre, y eso ya contesta la pregunta que más
337    pesa en una mudanza — **cuáles de estos dominios siguen apuntando acá y cuáles son fósiles**.
338    Lo que el DNS solo NO puede decir es si el backend contesta, así que esos dominios quedan en
339    `apunta-aca`, que es una clase propia y no `vivo`: decir «vivo» sin haber pedido una página
340    sería exactamente la respuesta falsa con forma de respuesta que este censo existe para evitar."""
341    ip = ""
342    try:
343        r = subprocess.run(["getent", "ahostsv4", d], capture_output=True, text=True, timeout=10)
344        if r.returncode == 0 and r.stdout.strip():
345            ip = r.stdout.split()[0]
346    except Exception:
347        pass
348    code = ""
349    if ip and http:
350        try:
351            r = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}",
352                                "--max-time", "8", f"https://{d}/"], capture_output=True, text=True, timeout=20)
353            code = r.stdout.strip() or "000"
354        except Exception:
355            pass
356    if not ip:
357        clase = "fosil-sin-dns"
358    elif ip not in ips_maquina:
359        clase = "ya-mudado"          # resuelve a otra máquina: el bloque de acá es fósil
360    elif not http:
361        clase = "apunta-aca"         # resuelve acá; si SIRVE no se preguntó, y no se inventa
362    elif code.startswith(("2", "3")):
363        clase = "vivo"
364    else:
365        clase = "backend-caido"
366    return {"domain": d, "dns": ip, "http": code, "class": clase}
367
368
369def datos(t, raices):
370    """Qué pesa y dónde. Sin esto no se puede dimensionar la mudanza — y `du` sobre hardlinks MIENTE
371    para dimensionar una COPIA (medido: 60 G por `du`, 85 G al copiar sin `-H`), así que se reportan
372    los dos números."""
373    res = []
374    for r in raices:
375        out, ok = t.run(f"du -sh {shlex.quote(r)} 2>/dev/null | cut -f1; "
376                        f"du -sh --count-links {shlex.quote(r)} 2>/dev/null | cut -f1")
377        ls = out.splitlines()
378        if ok and ls:
379            res.append({"path": r, "size": ls[0],
380                        "size_copy": ls[1] if len(ls) > 1 else ls[0]})
381    return res
382
383
384def montajes(t):
385    out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ")
386    res = []
387    for l in out.splitlines():
388        f = l.split()
389        if len(f) >= 6:
390            res.append({"dev": f[0], "size": f[1], "used": f[2], "avail": f[3], "mount": f[-1]})
391    return res
392
393
394def fuera_de_git(t):
395    """Lo que NO viaja con el repo y sin lo cual el servidor nuevo no funciona. Se reporta la
396    EXISTENCIA, nunca el contenido: un censo no copia secretos."""
397    cands = ["~/.ssh", "/root/.ssh", "~/.config/hammer", "/root/.config/hammer",
398             "/etc/caddy", "/etc/nginx", "/etc/letsencrypt", "/var/lib/gitea",
399             "/etc/arje/cards.d", "/ente"]
400    res = []
401    for c in cands:
402        out, ok = t.run(f"ls -d {c} >/dev/null 2>&1 && ls -1 {c} 2>/dev/null | head -20")
403        if ok:
404            res.append({"path": c, "entries": out.split() if out else []})
405    out, _ = t.run("crontab -l 2>/dev/null | grep -vE '^#|^$' | wc -l")
406    if out.isdigit() and int(out) > 0:
407        res.append({"path": "crontab", "entries": [f"{out} líneas"]})
408    return res
409
410
411# ── clasificación ───────────────────────────────────────────────────────────────────────────────
412
413# Nombres que NO son servicios: son el andamiaje del sistema o hijos de un servicio. Contarlos como
414# `no-declarado` es ruido, y un censo con hallazgos falsos se ignora entero.
415NO_SERVICIOS = {
416    "agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo",
417    "bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl",
418    # Restos de tubería reparentados a PID 1 cuando su padre muere. Medido: `head -15` figuraba como
419    # servicio `no-declarado` con ppid==1. No se esconden —salen en `descartados`, porque un huérfano
420    # ES un hallazgo— pero no son servicios y contarlos como tales es el ruido que mata un censo.
421    "head", "tr", "cut", "grep", "wc", "find", "xargs", "tee", "sort", "uniq", "env", "nohup",
422    "timeout", "flock", "tar", "gzip", "zstd", "dd", "md5sum", "sha256sum", "less", "more",
423}
424
425
426def normalizar(n):
427    """Un mismo servicio se llama distinto en cada sitio: OpenRC dice `act-runner`, `ps` dice
428    `act_runner`, systemd dice `act-runner.service`, y los getty por tty son `agetty.tty1`,
429    `agetty-tty2`… Sin normalizar, el MISMO proceso sale a la vez como `no-declarado` y como
430    `declarado-muerto`, que son las dos clases que más importan. Medido en gioser: pasaba con
431    `act_runner`."""
432    n = n.strip().lower()
433    for suf in (".service", ".socket", ".timer", ".target"):
434        if n.endswith(suf):
435            n = n[: -len(suf)]
436    n = n.replace("_", "-")
437    n = re.sub(r'[.\-]tty\d+$', '', n)       # agetty.tty1 / agetty-tty2 → agetty
438    n = re.sub(r'\d+$', '', n) if re.fullmatch(r'[a-z-]+\d+', n) else n
439    return n
440
441
442def emparenta(vivo, declarado):
443    """¿Son el mismo servicio con distinto nombre? Los desajustes son sistemáticos, no caprichosos:
444    el DEMONIO y la UNIDAD se llaman distinto. Medidos en gioser: `crond`↔`cronie`,
445    `dbus-daemon`↔`dbus`, `fail2ban-server`↔`fail2ban`, `elogind-daemon`↔`elogind`.
446
447    Sin esto, cada uno de ésos aparece a la vez como `no-declarado` (falso: sí está declarado) y como
448    `declarado-muerto` (falso: sí está corriendo) — las dos clases que más importan, las dos mal."""
449    if vivo == declarado:
450        return True
451    for suf in ("-daemon", "-server", "-bin"):
452        if vivo.endswith(suf) and vivo[: -len(suf)] == declarado:
453            return True
454    # `crond` → `cron`, prefijo de `cronie`. `sshd` → `ssh`. Sólo quitando UNA `d` final y exigiendo
455    # prefijo: quitar letras a ciegas emparenta cosas que no tienen nada que ver.
456    if len(vivo) > 4 and vivo.endswith("d") and declarado.startswith(vivo[:-1]):
457        return True
458    return False
459
460
461def es_nombre_de_servicio(n):
462    """Descarta lo que claramente no es un servicio: versiones (`2.1.240`), rutas, vacíos. Salieron
463    de parsear `rc-status -a`, cuya salida incluye líneas que no son unidades."""
464    return bool(re.fullmatch(r'[a-z][a-z0-9@._+-]{1,40}', n)) and not re.fullmatch(r'[\d.]+', n)
465
466
467def clasificar(vivos, decl):
468    """Las tres clases. `no-declarado` es la importante: corre y nadie sabe cómo levantarlo."""
469    # índice normalizado: nombre-canónico → fuentes que lo declaran
470    idx = {}
471    for fuente, nombres in decl.items():
472        for n in nombres:
473            if es_nombre_de_servicio(n.lower()):
474                idx.setdefault(normalizar(n), set()).add(fuente)
475
476    out, vistos, descartados = [], set(), []
477    for nombre, pids in sorted(vivos.items()):
478        canon = normalizar(nombre)
479        if canon in NO_SERVICIOS or not es_nombre_de_servicio(canon):
480            # No se esconde: un proceso con ppid==1 que no es un servicio suele ser un HUÉRFANO, y
481            # eso es un hallazgo (procesos fugados que retienen locks, por ejemplo).
482            descartados.append(nombre)
483            continue
484        fuentes = sorted(idx.get(canon, set()))
485        if not fuentes:  # segunda pasada: demonio vs unidad
486            emparentados = [f for d, fs in idx.items() if emparenta(canon, d) for f in fs]
487            fuentes = sorted(set(emparentados))
488            if fuentes:
489                vistos.update(d for d in idx if emparenta(canon, d))
490        vistos.add(canon)
491        out.append({"name": nombre, "pids": pids,
492                    "class": "vivo" if fuentes else "no-declarado",
493                    "declared_in": fuentes})
494    for canon in sorted(set(idx) - vistos):
495        if canon in NO_SERVICIOS:
496            continue
497        out.append({"name": canon, "pids": [],
498                    "class": "declarado-muerto",
499                    "declared_in": sorted(idx[canon])})
500    return out, descartados
501
502
503# ── salida ──────────────────────────────────────────────────────────────────────────────────────
504
505def toml_escape(s):
506    return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
507
508
509def emitir_toml(c):
510    L = []
511    L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo")
512    L.append("# para DECIDIR: la etapa siguiente lee `decision` de cada entrada.")
513    L.append(f"schema = \"takana-censo/1\"")
514    L.append(f"fecha = {toml_escape(c['fecha'])}")
515    L.append("")
516    L.append("[maquina]")
517    for k, v in c["identidad"].items():
518        L.append(f"{k} = {toml_escape(v) if isinstance(v, str) else json.dumps(v)}")
519    L.append("")
520    for m in c["montajes"]:
521        L.append("[[montaje]]")
522        for k, v in m.items():
523            L.append(f"{k} = {toml_escape(v)}")
524        L.append("")
525    for s in c["servicios"]:
526        L.append("[[servicio]]")
527        L.append(f"name = {toml_escape(s['name'])}")
528        L.append(f"class = {toml_escape(s['class'])}")
529        L.append(f"declared_in = {json.dumps(s['declared_in'])}")
530        L.append(f"pids = {json.dumps(s['pids'])}")
531        L.append(f"ports = {json.dumps([p['port'] for p in c['puertos'] if p['proc'].startswith(s['name'][:15])])}")
532        L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}")
533        L.append(f"cwd = {toml_escape(s.get('cwd',''))}")
534        L.append(f"exec = {toml_escape(s.get('exec',''))}")
535        if s.get('usuario'):
536            L.append(f"usuario = {toml_escape(s['usuario'])}")
537        if s.get('exec_motivo'):
538            L.append(f"exec_motivo = {toml_escape(s['exec_motivo'])}")
539        L.append(f"paquete = {toml_escape(s.get('paquete',''))}")
540        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
541        L.append("")
542    for d in c["dominios"]:
543        L.append("[[dominio]]")
544        for k, v in d.items():
545            L.append(f"{k} = {toml_escape(v)}")
546        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
547        L.append("")
548    for d in c["datos"]:
549        L.append("[[datos]]")
550        for k, v in d.items():
551            L.append(f"{k} = {toml_escape(v)}")
552        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
553        L.append("")
554    for f in c["fuera_de_git"]:
555        L.append("[[fuera_de_git]]")
556        L.append(f"path = {toml_escape(f['path'])}")
557        L.append(f"entries = {json.dumps(f['entries'])}")
558        L.append("")
559    return "\n".join(L) + "\n"
560
561
562def resumen(c):
563    i = c["identidad"]
564    print(f"\n══ CENSO de {i['hostname']} ({', '.join(i['ips']) or '?'}) ══")
565    print(f"   kernel {i['kernel']} · PID 1: {i['pid1']}")
566    por_clase = {}
567    for s in c["servicios"]:
568        por_clase.setdefault(s["class"], []).append(s["name"])
569    print("\n── servicios ──")
570    for cl in ("vivo", "no-declarado", "declarado-muerto"):
571        n = por_clase.get(cl, [])
572        marca = "⚠ " if cl == "no-declarado" else "  "
573        print(f"{marca}{cl:18} {len(n):3}  {' '.join(sorted(n)[:8])}{' …' if len(n) > 8 else ''}")
574    if c.get("huerfanos"):
575        print(f"   descartados (no son servicios: herramientas sueltas o HUÉRFANOS con ppid==1): "
576              f"{len(c['huerfanos'])}  {' '.join(sorted(c['huerfanos'])[:8])}")
577    if por_clase.get("no-declarado"):
578        print("   ⚠ `no-declarado` = corre y NADIE sabe cómo levantarlo. Es el trabajo de la mudanza.")
579    if c["dominios"]:
580        print("\n── dominios ──")
581        pd = {}
582        for d in c["dominios"]:
583            pd.setdefault(d["class"], []).append(d["domain"])
584        for cl in ("vivo", "apunta-aca", "ya-mudado", "backend-caido", "fosil-sin-dns", "sin-sondear"):
585            n = pd.get(cl, [])
586            if n:
587                print(f"  {cl:16} {len(n):3}  {' '.join(sorted(n)[:6])}{' …' if len(n) > 6 else ''}")
588    if c["datos"]:
589        print("\n── datos ──")
590        for d in c["datos"]:
591            extra = f"  (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else ""
592            print(f"  {d['path']:28} {d['size']:>7}{extra}")
593    print()
594
595
596def main():
597    ap = argparse.ArgumentParser(description="Censo de una máquina para mudarla. Sólo lee.")
598    ap.add_argument("--host", help="usuario@host a censar por SSH")
599    ap.add_argument("--key", help="clave SSH")
600    ap.add_argument("--local", action="store_true", help="censar esta máquina")
601    ap.add_argument("--out", help="fichero TOML de salida (default: stdout sólo el resumen)")
602    ap.add_argument("--probe", action="store_true",
603                    help="sondear los dominios por DNS+HTTP. Por defecto SÓLO con --host: hacerlo "
604                         "contra uno mismo puede disparar el fail2ban de la propia máquina")
605    ap.add_argument("--probe-dns", action="store_true",
606                    help="sondear SÓLO el DNS (sin HTTP): no toca al origen, así que es seguro "
607                         "contra uno mismo. Dice qué dominios son fósiles y cuáles apuntan acá")
608    ap.add_argument("--no-probe", action="store_true", help="no sondear dominios")
609    ap.add_argument("--data-root", action="append", default=[],
610                    help="raíz de datos a medir (repetible). Default: las habituales")
611    a = ap.parse_args()
612    if not a.host and not a.local:
613        ap.error("hace falta --host o --local")
614
615    t = Objetivo(a.host, a.key)
616    print("   censando… (no se ESCRIBE nada; las sondas de red sí dejan huella: ver --probe)",
617          file=sys.stderr)
618
619    ident = identidad(t)
620    vivos = procesos_vivos(t)
621    decl = declarados(t)
622    svc, huerfanos = clasificar(vivos, decl)
623    inv = invocaciones(t, vivos)
624    for e in svc:
625        i = inv.get(e['name'], {})
626        e['cmdline'] = i.get('cmdline', '')
627        e['cwd'] = i.get('cwd', '')
628        e['exe_real'] = i.get('exe', '')
629        if i.get('usuario'):
630            e['usuario'] = i['usuario']
631    # `/proc/<pid>/exe` MANDA sobre `argv[0]`: es el binario de verdad y no lo puede reescribir el
632    # proceso. `argv[0]` queda de respaldo para cuando la sonda no tuvo permiso.
633    def _exe_de(e):
634        return e.get('exe_real') or (
635            (e.get('cmdline') or '').split(' ')[0] if (e.get('cmdline') or '').startswith('/') else '')
636    ejes = [x for x in (_exe_de(e) for e in svc) if x]
637    dn = duenos(t, ejes)
638    raiz = os.geteuid() == 0 if a.local else None
639    for e in svc:
640        exe = _exe_de(e)
641        e['exec'] = exe
642        e['paquete'] = dn.get(exe, '')
643        if not exe and e.get('pids'):
644            # Decir POR QUÉ no se sabe. «Averigualo a mano» y «volvé a correr el censo con sudo» son
645            # dos trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
646            e['exec_motivo'] = ("el censo no corrió como root: `/proc/<pid>/exe` de un proceso de "
647                                "otro usuario no se puede leer. Repetilo con sudo"
648                                if raiz is False else
649                                "el proceso reescribió su argv[0] y no se pudo leer /proc/<pid>/exe")
650    pts = puertos(t)
651    nombres = dominios(t)
652    sondear = (a.probe or a.probe_dns or bool(a.host)) and not a.no_probe
653    http_ok = not a.probe_dns
654    if nombres and not sondear:
655        print(f"   ⚠ {len(nombres)} dominio(s) declarados, SIN sondear: censando en local, y pedirle",
656              file=sys.stderr)
657        print("     a la máquina sus propios dominios puede disparar su fail2ban y dejarla",
658              file=sys.stderr)
659        print("     incomunicada (pasó). Sondealos desde otra máquina, o forzá con --probe.",
660              file=sys.stderr)
661        doms = [{"domain": d, "dns": "", "http": "", "class": "sin-sondear"} for d in nombres]
662    else:
663        doms = []
664        for i, d in enumerate(nombres):
665            if i and http_ok:
666                time.sleep(0.5)   # no parecer un escaneo ante el propio fail2ban del objetivo
667            doms.append(sondear_dominio(d, set(ident["ips"]), http_ok))
668    raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store"]
669    dat = datos(t, raices)
670    censo = {
671        "fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
672        "identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts,
673        "dominios": doms, "datos": dat, "montajes": montajes(t),
674        "fuera_de_git": fuera_de_git(t),
675    }
676    resumen(censo)
677    if a.out:
678        with open(a.out, "w") as f:
679            f.write(emitir_toml(censo))
680        print(f"   censo → {a.out}   (decidí `decision` en cada entrada y pasalo a la etapa 2)")
681
682
683if __name__ == "__main__":
684    main()