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…
SDD 29 + censar.py: la mudanza como PRODUCTO — censo, plan, aplicación. Sin IA.
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
4Primer paso de la mudanza (SDD 29). Produce un inventario TOML que un humano revisa y que las etapas
5siguientes consumen. NO toca nada: todo lo que hace son lecturas.
6
7── LA REGLA QUE LO ORDENA TODO ─────────────────────────────────────────────────────────────────
8Cruzar SIEMPRE lo DECLARADO contra lo VIVO, y reportar las tres clases por separado:
9
10 vivo declarado Y corriendo ⇒ candidato a mudarse
11 declarado-muerto declarado y sin proceso ⇒ candidato a morir
12 no-declarado corriendo y sin declaración ⇒ EL TRABAJO REAL: nadie sabe cómo levantarlo
13
14No es teoría. Medido sobre gioser (2026-09-11):
15 · `rc-status` decía `stopped` de caddy, gitea, sshd, cronie y act-runner, y los CINCO estaban
16 vivos colgando de PID 1. Un censo que lea sólo el init habría producido un plan que no arranca.
17 · De los 19 dominios del Caddyfile, 8 no tenían DNS, 2 daban 502 y 3 ya vivían en OTRA máquina.
18 Sólo 6 eran reales. Un censo que lea sólo la config habría mudado 13 fósiles.
19 · Y el propio caddy de la caja NUEVA murió en un reinicio sin que nadie lo notara, porque estaba
20 arrancado a mano y no declarado. Esa es la clase `no-declarado`, y es la que duele.
21
22── POR QUÉ CADA SONDA ──────────────────────────────────────────────────────────────────────────
23`ppid==1` es la verdad de qué corre, no el init. Los sockets en LISTEN dicen qué SIRVE. El DNS y un
24GET dicen si alguien lo usa. `du` dice qué pesa. Nada de esto necesita juicio: son hechos, y el
25juicio (qué se muda, qué muere) lo pone el usuario en la etapa siguiente, sobre este fichero.
26
27Uso:
28 scripts/mudanza/censar.py --host root@1.2.3.4 [--key ~/.ssh/id] [--out censo.toml]
29 scripts/mudanza/censar.py --local # censa esta máquina
30
31La superficie va en inglés (CLAUDE.md regla 4); los mensajes, en castellano.
32"""
33import argparse, json, os, re, shlex, subprocess, sys, time
34
35# ── ejecución de sondas ─────────────────────────────────────────────────────────────────────────
36
37class Objetivo:
38 """Una máquina a censar. `run` devuelve (salida, ok) y NUNCA levanta: una sonda que falla es un
39 dato («esa máquina no tiene ss»), no un motivo para abortar el censo entero."""
40
41 def __init__(self, host=None, key=None, timeout=25):
42 self.host, self.key, self.timeout = host, key, timeout
43
44 def run(self, cmd):
45 if self.host:
46 base = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
47 "-o", f"ConnectTimeout={self.timeout}", "-o", "BatchMode=yes"]
48 if self.key:
49 base += ["-i", os.path.expanduser(self.key)]
50 argv = base + [self.host, cmd]
51 else:
52 argv = ["sh", "-c", cmd]
53 try:
54 p = subprocess.run(argv, capture_output=True, text=True, timeout=self.timeout * 4)
55 return p.stdout.strip(), p.returncode == 0
56 except Exception as e:
57 return f"(sonda falló: {e})", False
58
59
60# ── sondas ──────────────────────────────────────────────────────────────────────────────────────
61
62def identidad(t):
63 """Quién es esta máquina. El hostname MIENTE (gioser dice `momento`): la IP es el discriminador."""
64 out, _ = t.run("hostname; ip -4 addr show scope global 2>/dev/null | grep -o 'inet [0-9.]*' | "
65 "cut -d' ' -f2 | head -3; uname -r; cat /proc/1/comm")
66 ls = [l for l in out.splitlines() if l.strip()]
67 return {
68 "hostname": ls[0] if ls else "?",
69 "ips": [l for l in ls[1:-2]] if len(ls) > 3 else [],
70 "kernel": ls[-2] if len(ls) >= 2 else "?",
71 "pid1": ls[-1] if ls else "?",
72 }
73
74
75def procesos_vivos(t):
76 """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración."""
77 out, _ = t.run("ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && $1!=1 {print $1\" \"$3}'")
78 vivos = {}
79 for l in out.splitlines():
80 parts = l.split()
81 if len(parts) == 2 and parts[0].isdigit():
82 vivos.setdefault(parts[1], []).append(int(parts[0]))
83 # los kernel threads no son servicios
84 return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq",
85 "migration", "rcu_", "irq/", "idle_"))}
86
87
88def puertos(t):
89 """Qué SIRVE esta máquina, y quién. Un servicio sin puerto no se muda igual que uno con puerto."""
90 out, ok = t.run("ss -lntpH 2>/dev/null || netstat -lntp 2>/dev/null | tail -n +3")
91 res = []
92 for l in out.splitlines():
93 m = re.search(r'(\S+):(\d+)\s', l)
94 proc = re.search(r'\("([^"]+)"|/(\S+)\s*$', l)
95 if m:
96 res.append({"addr": m.group(1), "port": int(m.group(2)),
97 "proc": (proc.group(1) or proc.group(2)) if proc else "?"})
98 # dedup por (puerto, proc)
99 vistos, uniq = set(), []
100 for r in sorted(res, key=lambda x: x["port"]):
101 k = (r["port"], r["proc"])
102 if k not in vistos:
103 vistos.add(k); uniq.append(r)
104 return uniq
105
106
107def declarados(t):
108 """Lo que el init DICE que hay. Se compara contra lo vivo — no se cree."""
109 fuentes = {}
110 # `rc-status -a` lista TODO lo disponible en el sistema, no lo que esta máquina usa: en gioser
111 # daba 100 «declarado-muerto» que no significan nada. `rc-update show` da lo ENCENDIDO por
112 # runlevel, que es lo que de verdad se declara.
113 out, ok = t.run("rc-update show 2>/dev/null | awk '{print $1}' | grep -E '^[a-z]'")
114 if ok and out:
115 fuentes["openrc"] = out.split()
116 out, ok = t.run("systemctl list-units --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}'")
117 if ok and out:
118 fuentes["systemd"] = [s.replace(".service", "") for s in out.split()]
119 out, ok = t.run("ls /etc/arje/cards.d/ 2>/dev/null")
120 if ok and out:
121 fuentes["arje-cards"] = [s.replace(".json", "") for s in out.split()]
122 out, ok = t.run("python3 -c \"import json;print(' '.join(n['label'] for n in "
123 "json.load(open('/ente/seed.card.json'))['genesis']))\" 2>/dev/null")
124 if ok and out:
125 fuentes["arje-seed"] = out.split()
126 out, ok = t.run("ls /etc/service /run/runit/service 2>/dev/null")
127 if ok and out:
128 fuentes["runit"] = out.split()
129 return fuentes
130
131
132def dominios(t):
133 """Los dominios que la máquina DICE servir, contrastados con DNS y con un GET real.
134
135 Las tres respuestas posibles importan y son distintas: sin DNS (nadie lo resuelve), con DNS pero
136 5xx (backend caído), y con DNS y 200 (vivo). Y hay una cuarta que sólo se ve mirando a DÓNDE
137 resuelve: un dominio que responde 200 desde OTRA IP ya está mudado, y su bloque de config acá
138 es un fósil."""
139 confs = ["/etc/caddy/Caddyfile", "/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/*",
140 "/etc/apache2/sites-enabled/*", "/etc/httpd/conf.d/*"]
141 out, _ = t.run("cat " + " ".join(confs) + " 2>/dev/null")
142 nombres = set()
143 for l in out.splitlines():
144 s = l.strip()
145 if s.startswith("#"):
146 continue
147 # caddy: «a.com, b.com {» · nginx/apache: «server_name a.com b.com;» / «ServerName a.com»
148 if s.endswith("{") and not s.startswith(("@", "}")):
149 cand = s[:-1]
150 elif s.lower().startswith(("server_name", "servername", "serveralias")):
151 cand = s.split(None, 1)[1].rstrip(";") if len(s.split(None, 1)) > 1 else ""
152 else:
153 continue
154 for d in re.split(r'[,\s]+', cand):
155 d = d.strip().rstrip(";")
156 if re.fullmatch(r'[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9-]+)+', d, re.I):
157 nombres.add(d.lower())
158 return sorted(nombres)
159
160
161def sondear_dominio(d, ips_maquina):
162 """DNS + HTTP desde AFUERA (desde donde corre el censo). Determinista, sin juicio."""
163 ip = ""
164 try:
165 r = subprocess.run(["getent", "ahostsv4", d], capture_output=True, text=True, timeout=10)
166 if r.returncode == 0 and r.stdout.strip():
167 ip = r.stdout.split()[0]
168 except Exception:
169 pass
170 code = "000"
171 if ip:
172 try:
173 r = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}",
174 "--max-time", "8", f"https://{d}/"], capture_output=True, text=True, timeout=20)
175 code = r.stdout.strip() or "000"
176 except Exception:
177 pass
178 if not ip:
179 clase = "fosil-sin-dns"
180 elif ip not in ips_maquina:
181 clase = "ya-mudado" # resuelve a otra máquina: el bloque de acá es fósil
182 elif code.startswith(("2", "3")):
183 clase = "vivo"
184 else:
185 clase = "backend-caido"
186 return {"domain": d, "dns": ip, "http": code, "class": clase}
187
188
189def datos(t, raices):
190 """Qué pesa y dónde. Sin esto no se puede dimensionar la mudanza — y `du` sobre hardlinks MIENTE
191 para dimensionar una COPIA (medido: 60 G por `du`, 85 G al copiar sin `-H`), así que se reportan
192 los dos números."""
193 res = []
194 for r in raices:
195 out, ok = t.run(f"du -sh {shlex.quote(r)} 2>/dev/null | cut -f1; "
196 f"du -sh --count-links {shlex.quote(r)} 2>/dev/null | cut -f1")
197 ls = out.splitlines()
198 if ok and ls:
199 res.append({"path": r, "size": ls[0],
200 "size_copy": ls[1] if len(ls) > 1 else ls[0]})
201 return res
202
203
204def montajes(t):
205 out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ")
206 res = []
207 for l in out.splitlines():
208 f = l.split()
209 if len(f) >= 6:
210 res.append({"dev": f[0], "size": f[1], "used": f[2], "avail": f[3], "mount": f[-1]})
211 return res
212
213
214def fuera_de_git(t):
215 """Lo que NO viaja con el repo y sin lo cual el servidor nuevo no funciona. Se reporta la
216 EXISTENCIA, nunca el contenido: un censo no copia secretos."""
217 cands = ["~/.ssh", "/root/.ssh", "~/.config/hammer", "/root/.config/hammer",
218 "/etc/caddy", "/etc/nginx", "/etc/letsencrypt", "/var/lib/gitea",
219 "/etc/arje/cards.d", "/ente"]
220 res = []
221 for c in cands:
222 out, ok = t.run(f"ls -d {c} >/dev/null 2>&1 && ls -1 {c} 2>/dev/null | head -20")
223 if ok:
224 res.append({"path": c, "entries": out.split() if out else []})
225 out, _ = t.run("crontab -l 2>/dev/null | grep -vE '^#|^$' | wc -l")
226 if out.isdigit() and int(out) > 0:
227 res.append({"path": "crontab", "entries": [f"{out} líneas"]})
228 return res
229
230
231# ── clasificación ───────────────────────────────────────────────────────────────────────────────
232
233# Nombres que NO son servicios: son el andamiaje del sistema o hijos de un servicio. Contarlos como
234# `no-declarado` es ruido, y un censo con hallazgos falsos se ignora entero.
235NO_SERVICIOS = {
236 "agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo",
237 "bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl",
238}
239
240
241def normalizar(n):
242 """Un mismo servicio se llama distinto en cada sitio: OpenRC dice `act-runner`, `ps` dice
243 `act_runner`, systemd dice `act-runner.service`, y los getty por tty son `agetty.tty1`,
244 `agetty-tty2`… Sin normalizar, el MISMO proceso sale a la vez como `no-declarado` y como
245 `declarado-muerto`, que son las dos clases que más importan. Medido en gioser: pasaba con
246 `act_runner`."""
247 n = n.strip().lower()
248 for suf in (".service", ".socket", ".timer", ".target"):
249 if n.endswith(suf):
250 n = n[: -len(suf)]
251 n = n.replace("_", "-")
252 n = re.sub(r'[.\-]tty\d+$', '', n) # agetty.tty1 / agetty-tty2 → agetty
253 n = re.sub(r'\d+$', '', n) if re.fullmatch(r'[a-z-]+\d+', n) else n
254 return n
255
256
257def emparenta(vivo, declarado):
258 """¿Son el mismo servicio con distinto nombre? Los desajustes son sistemáticos, no caprichosos:
259 el DEMONIO y la UNIDAD se llaman distinto. Medidos en gioser: `crond`↔`cronie`,
260 `dbus-daemon`↔`dbus`, `fail2ban-server`↔`fail2ban`, `elogind-daemon`↔`elogind`.
261
262 Sin esto, cada uno de ésos aparece a la vez como `no-declarado` (falso: sí está declarado) y como
263 `declarado-muerto` (falso: sí está corriendo) — las dos clases que más importan, las dos mal."""
264 if vivo == declarado:
265 return True
266 for suf in ("-daemon", "-server", "-bin"):
267 if vivo.endswith(suf) and vivo[: -len(suf)] == declarado:
268 return True
269 # `crond` → `cron`, prefijo de `cronie`. `sshd` → `ssh`. Sólo quitando UNA `d` final y exigiendo
270 # prefijo: quitar letras a ciegas emparenta cosas que no tienen nada que ver.
271 if len(vivo) > 4 and vivo.endswith("d") and declarado.startswith(vivo[:-1]):
272 return True
273 return False
274
275
276def es_nombre_de_servicio(n):
277 """Descarta lo que claramente no es un servicio: versiones (`2.1.240`), rutas, vacíos. Salieron
278 de parsear `rc-status -a`, cuya salida incluye líneas que no son unidades."""
279 return bool(re.fullmatch(r'[a-z][a-z0-9@._+-]{1,40}', n)) and not re.fullmatch(r'[\d.]+', n)
280
281
282def clasificar(vivos, decl):
283 """Las tres clases. `no-declarado` es la importante: corre y nadie sabe cómo levantarlo."""
284 # índice normalizado: nombre-canónico → fuentes que lo declaran
285 idx = {}
286 for fuente, nombres in decl.items():
287 for n in nombres:
288 if es_nombre_de_servicio(n.lower()):
289 idx.setdefault(normalizar(n), set()).add(fuente)
290
291 out, vistos, descartados = [], set(), []
292 for nombre, pids in sorted(vivos.items()):
293 canon = normalizar(nombre)
294 if canon in NO_SERVICIOS or not es_nombre_de_servicio(canon):
295 # No se esconde: un proceso con ppid==1 que no es un servicio suele ser un HUÉRFANO, y
296 # eso es un hallazgo (procesos fugados que retienen locks, por ejemplo).
297 descartados.append(nombre)
298 continue
299 fuentes = sorted(idx.get(canon, set()))
300 if not fuentes: # segunda pasada: demonio vs unidad
301 emparentados = [f for d, fs in idx.items() if emparenta(canon, d) for f in fs]
302 fuentes = sorted(set(emparentados))
303 if fuentes:
304 vistos.update(d for d in idx if emparenta(canon, d))
305 vistos.add(canon)
306 out.append({"name": nombre, "pids": pids,
307 "class": "vivo" if fuentes else "no-declarado",
308 "declared_in": fuentes})
309 for canon in sorted(set(idx) - vistos):
310 if canon in NO_SERVICIOS:
311 continue
312 out.append({"name": canon, "pids": [],
313 "class": "declarado-muerto",
314 "declared_in": sorted(idx[canon])})
315 return out, descartados
316
317
318# ── salida ──────────────────────────────────────────────────────────────────────────────────────
319
320def toml_escape(s):
321 return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
322
323
324def emitir_toml(c):
325 L = []
326 L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo")
327 L.append("# para DECIDIR: la etapa siguiente lee `decision` de cada entrada.")
328 L.append(f"schema = \"takana-censo/1\"")
329 L.append(f"fecha = {toml_escape(c['fecha'])}")
330 L.append("")
331 L.append("[maquina]")
332 for k, v in c["identidad"].items():
333 L.append(f"{k} = {toml_escape(v) if isinstance(v, str) else json.dumps(v)}")
334 L.append("")
335 for m in c["montajes"]:
336 L.append("[[montaje]]")
337 for k, v in m.items():
338 L.append(f"{k} = {toml_escape(v)}")
339 L.append("")
340 for s in c["servicios"]:
341 L.append("[[servicio]]")
342 L.append(f"name = {toml_escape(s['name'])}")
343 L.append(f"class = {toml_escape(s['class'])}")
344 L.append(f"declared_in = {json.dumps(s['declared_in'])}")
345 L.append(f"pids = {json.dumps(s['pids'])}")
346 L.append(f"ports = {json.dumps([p['port'] for p in c['puertos'] if p['proc'].startswith(s['name'][:15])])}")
347 L.append('decision = "" # muda | muere | (vacío = sin decidir)')
348 L.append("")
349 for d in c["dominios"]:
350 L.append("[[dominio]]")
351 for k, v in d.items():
352 L.append(f"{k} = {toml_escape(v)}")
353 L.append('decision = "" # muda | muere | (vacío = sin decidir)')
354 L.append("")
355 for d in c["datos"]:
356 L.append("[[datos]]")
357 for k, v in d.items():
358 L.append(f"{k} = {toml_escape(v)}")
359 L.append('decision = "" # muda | muere | (vacío = sin decidir)')
360 L.append("")
361 for f in c["fuera_de_git"]:
362 L.append("[[fuera_de_git]]")
363 L.append(f"path = {toml_escape(f['path'])}")
364 L.append(f"entries = {json.dumps(f['entries'])}")
365 L.append("")
366 return "\n".join(L) + "\n"
367
368
369def resumen(c):
370 i = c["identidad"]
371 print(f"\n══ CENSO de {i['hostname']} ({', '.join(i['ips']) or '?'}) ══")
372 print(f" kernel {i['kernel']} · PID 1: {i['pid1']}")
373 por_clase = {}
374 for s in c["servicios"]:
375 por_clase.setdefault(s["class"], []).append(s["name"])
376 print("\n── servicios ──")
377 for cl in ("vivo", "no-declarado", "declarado-muerto"):
378 n = por_clase.get(cl, [])
379 marca = "⚠ " if cl == "no-declarado" else " "
380 print(f"{marca}{cl:18} {len(n):3} {' '.join(sorted(n)[:8])}{' …' if len(n) > 8 else ''}")
381 if c.get("huerfanos"):
382 print(f" descartados (no son servicios: herramientas sueltas o HUÉRFANOS con ppid==1): "
383 f"{len(c['huerfanos'])} {' '.join(sorted(c['huerfanos'])[:8])}")
384 if por_clase.get("no-declarado"):
385 print(" ⚠ `no-declarado` = corre y NADIE sabe cómo levantarlo. Es el trabajo de la mudanza.")
386 if c["dominios"]:
387 print("\n── dominios ──")
388 pd = {}
389 for d in c["dominios"]:
390 pd.setdefault(d["class"], []).append(d["domain"])
391 for cl in ("vivo", "ya-mudado", "backend-caido", "fosil-sin-dns"):
392 n = pd.get(cl, [])
393 if n:
394 print(f" {cl:16} {len(n):3} {' '.join(sorted(n)[:6])}{' …' if len(n) > 6 else ''}")
395 if c["datos"]:
396 print("\n── datos ──")
397 for d in c["datos"]:
398 extra = f" (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else ""
399 print(f" {d['path']:28} {d['size']:>7}{extra}")
400 print()
401
402
403def main():
404 ap = argparse.ArgumentParser(description="Censo de una máquina para mudarla. Sólo lee.")
405 ap.add_argument("--host", help="usuario@host a censar por SSH")
406 ap.add_argument("--key", help="clave SSH")
407 ap.add_argument("--local", action="store_true", help="censar esta máquina")
408 ap.add_argument("--out", help="fichero TOML de salida (default: stdout sólo el resumen)")
409 ap.add_argument("--data-root", action="append", default=[],
410 help="raíz de datos a medir (repetible). Default: las habituales")
411 a = ap.parse_args()
412 if not a.host and not a.local:
413 ap.error("hace falta --host o --local")
414
415 t = Objetivo(a.host, a.key)
416 print(" censando… (sólo lecturas, no se toca nada)", file=sys.stderr)
417
418 ident = identidad(t)
419 vivos = procesos_vivos(t)
420 decl = declarados(t)
421 svc, huerfanos = clasificar(vivos, decl)
422 pts = puertos(t)
423 doms = [sondear_dominio(d, set(ident["ips"])) for d in dominios(t)]
424 raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store"]
425 dat = datos(t, raices)
426 censo = {
427 "fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
428 "identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts,
429 "dominios": doms, "datos": dat, "montajes": montajes(t),
430 "fuera_de_git": fuera_de_git(t),
431 }
432 resumen(censo)
433 if a.out:
434 with open(a.out, "w") as f:
435 f.write(emitir_toml(censo))
436 print(f" censo → {a.out} (decidí `decision` en cada entrada y pasalo a la etapa 2)")
437
438
439if __name__ == "__main__":
440 main()