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…
mudanza etapa 3: `declarar.py` — de «corre y nadie sabe cómo» a una Semilla que arranca
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()