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 1184Shell 71Rust 62Markdown 501503 Definiciones

⎇ 1a89592b7252782a72e323ba6c89f0b862126600 scripts/mudanza/censar.py Python · 510 líneas · 24.6 KB Historia Quién tocó qué crudo

mudanza etapa 3: `declarar.py` — de «corre y nadie sabe cómo» a una Semilla que arranca 1a89592b7 · 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        cmd, cwd = cmd.strip(), cwd.strip()
98        if cmd.startswith("(sonda falló"):
99            cmd = ""
100        if cwd.startswith("(sonda falló"):
101            cwd = ""
102        if cmd or cwd:
103            out[nombre] = {"cmdline": cmd, "cwd": cwd}
104    return out
105
106
107def procesos_vivos(t):
108    """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración."""
109    out, _ = t.run("ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && $1!=1 {print $1\" \"$3}'")
110    vivos = {}
111    for l in out.splitlines():
112        parts = l.split()
113        if len(parts) == 2 and parts[0].isdigit():
114            vivos.setdefault(parts[1], []).append(int(parts[0]))
115    # los kernel threads no son servicios
116    return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq",
117                                                                "migration", "rcu_", "irq/", "idle_"))}
118
119
120def puertos(t):
121    """Qué SIRVE esta máquina, y quién. Un servicio sin puerto no se muda igual que uno con puerto."""
122    out, ok = t.run("ss -lntpH 2>/dev/null || netstat -lntp 2>/dev/null | tail -n +3")
123    res = []
124    for l in out.splitlines():
125        m = re.search(r'(\S+):(\d+)\s', l)
126        proc = re.search(r'\("([^"]+)"|/(\S+)\s*$', l)
127        if m:
128            res.append({"addr": m.group(1), "port": int(m.group(2)),
129                        "proc": (proc.group(1) or proc.group(2)) if proc else "?"})
130    # dedup por (puerto, proc)
131    vistos, uniq = set(), []
132    for r in sorted(res, key=lambda x: x["port"]):
133        k = (r["port"], r["proc"])
134        if k not in vistos:
135            vistos.add(k); uniq.append(r)
136    return uniq
137
138
139def declarados(t):
140    """Lo que el init DICE que hay. Se compara contra lo vivo — no se cree."""
141    fuentes = {}
142    # `rc-status -a` lista TODO lo disponible en el sistema, no lo que esta máquina usa: en gioser
143    # daba 100 «declarado-muerto» que no significan nada. `rc-update show` da lo ENCENDIDO por
144    # runlevel, que es lo que de verdad se declara.
145    out, ok = t.run("rc-update show 2>/dev/null | awk '{print $1}' | grep -E '^[a-z]'")
146    if ok and out:
147        fuentes["openrc"] = out.split()
148    out, ok = t.run("systemctl list-units --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}'")
149    if ok and out:
150        fuentes["systemd"] = [s.replace(".service", "") for s in out.split()]
151    out, ok = t.run("ls /etc/arje/cards.d/ 2>/dev/null")
152    if ok and out:
153        fuentes["arje-cards"] = [s.replace(".json", "") for s in out.split()]
154    out, ok = t.run("python3 -c \"import json;print(' '.join(n['label'] for n in "
155                    "json.load(open('/ente/seed.card.json'))['genesis']))\" 2>/dev/null")
156    if ok and out:
157        fuentes["arje-seed"] = out.split()
158    out, ok = t.run("ls /etc/service /run/runit/service 2>/dev/null")
159    if ok and out:
160        fuentes["runit"] = out.split()
161    return fuentes
162
163
164def dominios(t):
165    """Los dominios que la máquina DICE servir, contrastados con DNS y con un GET real.
166
167    Las tres respuestas posibles importan y son distintas: sin DNS (nadie lo resuelve), con DNS pero
168    5xx (backend caído), y con DNS y 200 (vivo). Y hay una cuarta que sólo se ve mirando a DÓNDE
169    resuelve: un dominio que responde 200 desde OTRA IP ya está mudado, y su bloque de config acá
170    es un fósil."""
171    confs = ["/etc/caddy/Caddyfile", "/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/*",
172             "/etc/apache2/sites-enabled/*", "/etc/httpd/conf.d/*"]
173    out, _ = t.run("cat " + " ".join(confs) + " 2>/dev/null")
174    nombres = set()
175    for l in out.splitlines():
176        s = l.strip()
177        if s.startswith("#"):
178            continue
179        # caddy: «a.com, b.com {» · nginx/apache: «server_name a.com b.com;» / «ServerName a.com»
180        if s.endswith("{") and not s.startswith(("@", "}")):
181            cand = s[:-1]
182        elif s.lower().startswith(("server_name", "servername", "serveralias")):
183            cand = s.split(None, 1)[1].rstrip(";") if len(s.split(None, 1)) > 1 else ""
184        else:
185            continue
186        for d in re.split(r'[,\s]+', cand):
187            d = d.strip().rstrip(";")
188            if re.fullmatch(r'[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9-]+)+', d, re.I):
189                nombres.add(d.lower())
190    return sorted(nombres)
191
192
193def sondear_dominio(d, ips_maquina):
194    """DNS + HTTP desde donde corre el censo. Determinista, sin juicio.
195
196    ⚠⚠ **NO se hace contra la máquina que uno está censando desde ella misma.** «Sólo lee» era falso:
197    leer POR RED tiene efectos. Medido el 2026-09-11 — censar gioser en modo `--local` disparó 29
198    peticiones HTTPS a sus propios dominios, varios devuelven 502, y su `fail2ban` (jaula
199    `caddy-backend-down`) **baneó la propia IP de la máquina**. Resultado: gioser dejó de poder hablar
200    con su propio gitea y los `git push` empezaron a fallar con «Could not read from remote
201    repository». Un censo que deja la máquina incomunicada no es de sólo lectura.
202
203    Y además es metodológicamente mejor: lo que interesa es **qué ve el mundo**, no qué ve la máquina
204    de sí misma. Por eso la sonda se hace desde donde corre el censo —el operador o la caja nueva— y
205    se SALTA cuando se censa en local, salvo `--probe` explícito."""
206    ip = ""
207    try:
208        r = subprocess.run(["getent", "ahostsv4", d], capture_output=True, text=True, timeout=10)
209        if r.returncode == 0 and r.stdout.strip():
210            ip = r.stdout.split()[0]
211    except Exception:
212        pass
213    code = "000"
214    if ip:
215        try:
216            r = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}",
217                                "--max-time", "8", f"https://{d}/"], capture_output=True, text=True, timeout=20)
218            code = r.stdout.strip() or "000"
219        except Exception:
220            pass
221    if not ip:
222        clase = "fosil-sin-dns"
223    elif ip not in ips_maquina:
224        clase = "ya-mudado"          # resuelve a otra máquina: el bloque de acá es fósil
225    elif code.startswith(("2", "3")):
226        clase = "vivo"
227    else:
228        clase = "backend-caido"
229    return {"domain": d, "dns": ip, "http": code, "class": clase}
230
231
232def datos(t, raices):
233    """Qué pesa y dónde. Sin esto no se puede dimensionar la mudanza — y `du` sobre hardlinks MIENTE
234    para dimensionar una COPIA (medido: 60 G por `du`, 85 G al copiar sin `-H`), así que se reportan
235    los dos números."""
236    res = []
237    for r in raices:
238        out, ok = t.run(f"du -sh {shlex.quote(r)} 2>/dev/null | cut -f1; "
239                        f"du -sh --count-links {shlex.quote(r)} 2>/dev/null | cut -f1")
240        ls = out.splitlines()
241        if ok and ls:
242            res.append({"path": r, "size": ls[0],
243                        "size_copy": ls[1] if len(ls) > 1 else ls[0]})
244    return res
245
246
247def montajes(t):
248    out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ")
249    res = []
250    for l in out.splitlines():
251        f = l.split()
252        if len(f) >= 6:
253            res.append({"dev": f[0], "size": f[1], "used": f[2], "avail": f[3], "mount": f[-1]})
254    return res
255
256
257def fuera_de_git(t):
258    """Lo que NO viaja con el repo y sin lo cual el servidor nuevo no funciona. Se reporta la
259    EXISTENCIA, nunca el contenido: un censo no copia secretos."""
260    cands = ["~/.ssh", "/root/.ssh", "~/.config/hammer", "/root/.config/hammer",
261             "/etc/caddy", "/etc/nginx", "/etc/letsencrypt", "/var/lib/gitea",
262             "/etc/arje/cards.d", "/ente"]
263    res = []
264    for c in cands:
265        out, ok = t.run(f"ls -d {c} >/dev/null 2>&1 && ls -1 {c} 2>/dev/null | head -20")
266        if ok:
267            res.append({"path": c, "entries": out.split() if out else []})
268    out, _ = t.run("crontab -l 2>/dev/null | grep -vE '^#|^$' | wc -l")
269    if out.isdigit() and int(out) > 0:
270        res.append({"path": "crontab", "entries": [f"{out} líneas"]})
271    return res
272
273
274# ── clasificación ───────────────────────────────────────────────────────────────────────────────
275
276# Nombres que NO son servicios: son el andamiaje del sistema o hijos de un servicio. Contarlos como
277# `no-declarado` es ruido, y un censo con hallazgos falsos se ignora entero.
278NO_SERVICIOS = {
279    "agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo",
280    "bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl",
281}
282
283
284def normalizar(n):
285    """Un mismo servicio se llama distinto en cada sitio: OpenRC dice `act-runner`, `ps` dice
286    `act_runner`, systemd dice `act-runner.service`, y los getty por tty son `agetty.tty1`,
287    `agetty-tty2`… Sin normalizar, el MISMO proceso sale a la vez como `no-declarado` y como
288    `declarado-muerto`, que son las dos clases que más importan. Medido en gioser: pasaba con
289    `act_runner`."""
290    n = n.strip().lower()
291    for suf in (".service", ".socket", ".timer", ".target"):
292        if n.endswith(suf):
293            n = n[: -len(suf)]
294    n = n.replace("_", "-")
295    n = re.sub(r'[.\-]tty\d+$', '', n)       # agetty.tty1 / agetty-tty2 → agetty
296    n = re.sub(r'\d+$', '', n) if re.fullmatch(r'[a-z-]+\d+', n) else n
297    return n
298
299
300def emparenta(vivo, declarado):
301    """¿Son el mismo servicio con distinto nombre? Los desajustes son sistemáticos, no caprichosos:
302    el DEMONIO y la UNIDAD se llaman distinto. Medidos en gioser: `crond`↔`cronie`,
303    `dbus-daemon`↔`dbus`, `fail2ban-server`↔`fail2ban`, `elogind-daemon`↔`elogind`.
304
305    Sin esto, cada uno de ésos aparece a la vez como `no-declarado` (falso: sí está declarado) y como
306    `declarado-muerto` (falso: sí está corriendo) — las dos clases que más importan, las dos mal."""
307    if vivo == declarado:
308        return True
309    for suf in ("-daemon", "-server", "-bin"):
310        if vivo.endswith(suf) and vivo[: -len(suf)] == declarado:
311            return True
312    # `crond` → `cron`, prefijo de `cronie`. `sshd` → `ssh`. Sólo quitando UNA `d` final y exigiendo
313    # prefijo: quitar letras a ciegas emparenta cosas que no tienen nada que ver.
314    if len(vivo) > 4 and vivo.endswith("d") and declarado.startswith(vivo[:-1]):
315        return True
316    return False
317
318
319def es_nombre_de_servicio(n):
320    """Descarta lo que claramente no es un servicio: versiones (`2.1.240`), rutas, vacíos. Salieron
321    de parsear `rc-status -a`, cuya salida incluye líneas que no son unidades."""
322    return bool(re.fullmatch(r'[a-z][a-z0-9@._+-]{1,40}', n)) and not re.fullmatch(r'[\d.]+', n)
323
324
325def clasificar(vivos, decl):
326    """Las tres clases. `no-declarado` es la importante: corre y nadie sabe cómo levantarlo."""
327    # índice normalizado: nombre-canónico → fuentes que lo declaran
328    idx = {}
329    for fuente, nombres in decl.items():
330        for n in nombres:
331            if es_nombre_de_servicio(n.lower()):
332                idx.setdefault(normalizar(n), set()).add(fuente)
333
334    out, vistos, descartados = [], set(), []
335    for nombre, pids in sorted(vivos.items()):
336        canon = normalizar(nombre)
337        if canon in NO_SERVICIOS or not es_nombre_de_servicio(canon):
338            # No se esconde: un proceso con ppid==1 que no es un servicio suele ser un HUÉRFANO, y
339            # eso es un hallazgo (procesos fugados que retienen locks, por ejemplo).
340            descartados.append(nombre)
341            continue
342        fuentes = sorted(idx.get(canon, set()))
343        if not fuentes:  # segunda pasada: demonio vs unidad
344            emparentados = [f for d, fs in idx.items() if emparenta(canon, d) for f in fs]
345            fuentes = sorted(set(emparentados))
346            if fuentes:
347                vistos.update(d for d in idx if emparenta(canon, d))
348        vistos.add(canon)
349        out.append({"name": nombre, "pids": pids,
350                    "class": "vivo" if fuentes else "no-declarado",
351                    "declared_in": fuentes})
352    for canon in sorted(set(idx) - vistos):
353        if canon in NO_SERVICIOS:
354            continue
355        out.append({"name": canon, "pids": [],
356                    "class": "declarado-muerto",
357                    "declared_in": sorted(idx[canon])})
358    return out, descartados
359
360
361# ── salida ──────────────────────────────────────────────────────────────────────────────────────
362
363def toml_escape(s):
364    return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
365
366
367def emitir_toml(c):
368    L = []
369    L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo")
370    L.append("# para DECIDIR: la etapa siguiente lee `decision` de cada entrada.")
371    L.append(f"schema = \"takana-censo/1\"")
372    L.append(f"fecha = {toml_escape(c['fecha'])}")
373    L.append("")
374    L.append("[maquina]")
375    for k, v in c["identidad"].items():
376        L.append(f"{k} = {toml_escape(v) if isinstance(v, str) else json.dumps(v)}")
377    L.append("")
378    for m in c["montajes"]:
379        L.append("[[montaje]]")
380        for k, v in m.items():
381            L.append(f"{k} = {toml_escape(v)}")
382        L.append("")
383    for s in c["servicios"]:
384        L.append("[[servicio]]")
385        L.append(f"name = {toml_escape(s['name'])}")
386        L.append(f"class = {toml_escape(s['class'])}")
387        L.append(f"declared_in = {json.dumps(s['declared_in'])}")
388        L.append(f"pids = {json.dumps(s['pids'])}")
389        L.append(f"ports = {json.dumps([p['port'] for p in c['puertos'] if p['proc'].startswith(s['name'][:15])])}")
390        L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}")
391        L.append(f"cwd = {toml_escape(s.get('cwd',''))}")
392        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
393        L.append("")
394    for d in c["dominios"]:
395        L.append("[[dominio]]")
396        for k, v in d.items():
397            L.append(f"{k} = {toml_escape(v)}")
398        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
399        L.append("")
400    for d in c["datos"]:
401        L.append("[[datos]]")
402        for k, v in d.items():
403            L.append(f"{k} = {toml_escape(v)}")
404        L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
405        L.append("")
406    for f in c["fuera_de_git"]:
407        L.append("[[fuera_de_git]]")
408        L.append(f"path = {toml_escape(f['path'])}")
409        L.append(f"entries = {json.dumps(f['entries'])}")
410        L.append("")
411    return "\n".join(L) + "\n"
412
413
414def resumen(c):
415    i = c["identidad"]
416    print(f"\n══ CENSO de {i['hostname']} ({', '.join(i['ips']) or '?'}) ══")
417    print(f"   kernel {i['kernel']} · PID 1: {i['pid1']}")
418    por_clase = {}
419    for s in c["servicios"]:
420        por_clase.setdefault(s["class"], []).append(s["name"])
421    print("\n── servicios ──")
422    for cl in ("vivo", "no-declarado", "declarado-muerto"):
423        n = por_clase.get(cl, [])
424        marca = "⚠ " if cl == "no-declarado" else "  "
425        print(f"{marca}{cl:18} {len(n):3}  {' '.join(sorted(n)[:8])}{' …' if len(n) > 8 else ''}")
426    if c.get("huerfanos"):
427        print(f"   descartados (no son servicios: herramientas sueltas o HUÉRFANOS con ppid==1): "
428              f"{len(c['huerfanos'])}  {' '.join(sorted(c['huerfanos'])[:8])}")
429    if por_clase.get("no-declarado"):
430        print("   ⚠ `no-declarado` = corre y NADIE sabe cómo levantarlo. Es el trabajo de la mudanza.")
431    if c["dominios"]:
432        print("\n── dominios ──")
433        pd = {}
434        for d in c["dominios"]:
435            pd.setdefault(d["class"], []).append(d["domain"])
436        for cl in ("vivo", "ya-mudado", "backend-caido", "fosil-sin-dns", "sin-sondear"):
437            n = pd.get(cl, [])
438            if n:
439                print(f"  {cl:16} {len(n):3}  {' '.join(sorted(n)[:6])}{' …' if len(n) > 6 else ''}")
440    if c["datos"]:
441        print("\n── datos ──")
442        for d in c["datos"]:
443            extra = f"  (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else ""
444            print(f"  {d['path']:28} {d['size']:>7}{extra}")
445    print()
446
447
448def main():
449    ap = argparse.ArgumentParser(description="Censo de una máquina para mudarla. Sólo lee.")
450    ap.add_argument("--host", help="usuario@host a censar por SSH")
451    ap.add_argument("--key", help="clave SSH")
452    ap.add_argument("--local", action="store_true", help="censar esta máquina")
453    ap.add_argument("--out", help="fichero TOML de salida (default: stdout sólo el resumen)")
454    ap.add_argument("--probe", action="store_true",
455                    help="sondear los dominios por DNS+HTTP. Por defecto SÓLO con --host: hacerlo "
456                         "contra uno mismo puede disparar el fail2ban de la propia máquina")
457    ap.add_argument("--no-probe", action="store_true", help="no sondear dominios")
458    ap.add_argument("--data-root", action="append", default=[],
459                    help="raíz de datos a medir (repetible). Default: las habituales")
460    a = ap.parse_args()
461    if not a.host and not a.local:
462        ap.error("hace falta --host o --local")
463
464    t = Objetivo(a.host, a.key)
465    print("   censando… (no se ESCRIBE nada; las sondas de red sí dejan huella: ver --probe)",
466          file=sys.stderr)
467
468    ident = identidad(t)
469    vivos = procesos_vivos(t)
470    decl = declarados(t)
471    svc, huerfanos = clasificar(vivos, decl)
472    inv = invocaciones(t, vivos)
473    for e in svc:
474        i = inv.get(e['name'], {})
475        e['cmdline'] = i.get('cmdline', '')
476        e['cwd'] = i.get('cwd', '')
477    pts = puertos(t)
478    nombres = dominios(t)
479    sondear = (a.probe or bool(a.host)) and not a.no_probe
480    if nombres and not sondear:
481        print(f"   ⚠ {len(nombres)} dominio(s) declarados, SIN sondear: censando en local, y pedirle",
482              file=sys.stderr)
483        print("     a la máquina sus propios dominios puede disparar su fail2ban y dejarla",
484              file=sys.stderr)
485        print("     incomunicada (pasó). Sondealos desde otra máquina, o forzá con --probe.",
486              file=sys.stderr)
487        doms = [{"domain": d, "dns": "", "http": "", "class": "sin-sondear"} for d in nombres]
488    else:
489        doms = []
490        for i, d in enumerate(nombres):
491            if i:
492                time.sleep(0.5)   # no parecer un escaneo ante el propio fail2ban del objetivo
493            doms.append(sondear_dominio(d, set(ident["ips"])))
494    raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store"]
495    dat = datos(t, raices)
496    censo = {
497        "fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
498        "identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts,
499        "dominios": doms, "datos": dat, "montajes": montajes(t),
500        "fuera_de_git": fuera_de_git(t),
501    }
502    resumen(censo)
503    if a.out:
504        with open(a.out, "w") as f:
505            f.write(emitir_toml(censo))
506        print(f"   censo → {a.out}   (decidí `decision` en cada entrada y pasalo a la etapa 2)")
507
508
509if __name__ == "__main__":
510    main()