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