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