#!/usr/bin/env python3 """censar.py — CENSO de una máquina: qué corre, qué sirve, qué datos tiene. Sin IA, sin adivinar. ⚠ «Sólo lee» hay que decirlo con cuidado: NO ESCRIBE nada, pero las sondas de RED dejan huella. Ver `sondear_dominio` — censar gioser contra sí mismo hizo que su propio fail2ban lo baneara. Primer paso de la mudanza (SDD 29). Produce un inventario TOML que un humano revisa y que las etapas siguientes consumen. NO toca nada: todo lo que hace son lecturas. ── LA REGLA QUE LO ORDENA TODO ───────────────────────────────────────────────────────────────── Cruzar SIEMPRE lo DECLARADO contra lo VIVO, y reportar las tres clases por separado: vivo declarado Y corriendo ⇒ candidato a mudarse declarado-muerto declarado y sin proceso ⇒ candidato a morir no-declarado corriendo y sin declaración ⇒ EL TRABAJO REAL: nadie sabe cómo levantarlo No es teoría. Medido sobre gioser (2026-09-11): · `rc-status` decía `stopped` de caddy, gitea, sshd, cronie y act-runner, y los CINCO estaban vivos colgando de PID 1. Un censo que lea sólo el init habría producido un plan que no arranca. · De los 19 dominios del Caddyfile, 8 no tenían DNS, 2 daban 502 y 3 ya vivían en OTRA máquina. Sólo 6 eran reales. Un censo que lea sólo la config habría mudado 13 fósiles. · Y el propio caddy de la caja NUEVA murió en un reinicio sin que nadie lo notara, porque estaba arrancado a mano y no declarado. Esa es la clase `no-declarado`, y es la que duele. ── POR QUÉ CADA SONDA ────────────────────────────────────────────────────────────────────────── `ppid==1` es la verdad de qué corre, no el init. Los sockets en LISTEN dicen qué SIRVE. El DNS y un GET dicen si alguien lo usa. `du` dice qué pesa. Nada de esto necesita juicio: son hechos, y el juicio (qué se muda, qué muere) lo pone el usuario en la etapa siguiente, sobre este fichero. Uso: scripts/mudanza/censar.py --host root@1.2.3.4 [--key ~/.ssh/id] [--out censo.toml] scripts/mudanza/censar.py --local # censa esta máquina La superficie va en inglés (CLAUDE.md regla 4); los mensajes, en castellano. """ import argparse, json, os, re, shlex, subprocess, sys, time # ── ejecución de sondas ───────────────────────────────────────────────────────────────────────── class Objetivo: """Una máquina a censar. `run` devuelve (salida, ok) y NUNCA levanta: una sonda que falla es un dato («esa máquina no tiene ss»), no un motivo para abortar el censo entero.""" def __init__(self, host=None, key=None, timeout=25): self.host, self.key, self.timeout = host, key, timeout def run(self, cmd): if self.host: base = ["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", f"ConnectTimeout={self.timeout}", "-o", "BatchMode=yes"] if self.key: base += ["-i", os.path.expanduser(self.key)] argv = base + [self.host, cmd] else: argv = ["sh", "-c", cmd] try: p = subprocess.run(argv, capture_output=True, text=True, timeout=self.timeout * 4) return p.stdout.strip(), p.returncode == 0 except Exception as e: return f"(sonda falló: {e})", False # ── sondas ────────────────────────────────────────────────────────────────────────────────────── def identidad(t): """Quién es esta máquina. El hostname MIENTE (gioser dice `momento`): la IP es el discriminador.""" out, _ = t.run("hostname; ip -4 addr show scope global 2>/dev/null | grep -o 'inet [0-9.]*' | " "cut -d' ' -f2 | head -3; uname -r; cat /proc/1/comm") ls = [l for l in out.splitlines() if l.strip()] return { "hostname": ls[0] if ls else "?", "ips": [l for l in ls[1:-2]] if len(ls) > 3 else [], "kernel": ls[-2] if len(ls) >= 2 else "?", "pid1": ls[-1] if ls else "?", } def procesos_vivos(t): """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración.""" out, _ = t.run("ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && $1!=1 {print $1\" \"$3}'") vivos = {} for l in out.splitlines(): parts = l.split() if len(parts) == 2 and parts[0].isdigit(): vivos.setdefault(parts[1], []).append(int(parts[0])) # los kernel threads no son servicios return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq", "migration", "rcu_", "irq/", "idle_"))} def puertos(t): """Qué SIRVE esta máquina, y quién. Un servicio sin puerto no se muda igual que uno con puerto.""" out, ok = t.run("ss -lntpH 2>/dev/null || netstat -lntp 2>/dev/null | tail -n +3") res = [] for l in out.splitlines(): m = re.search(r'(\S+):(\d+)\s', l) proc = re.search(r'\("([^"]+)"|/(\S+)\s*$', l) if m: res.append({"addr": m.group(1), "port": int(m.group(2)), "proc": (proc.group(1) or proc.group(2)) if proc else "?"}) # dedup por (puerto, proc) vistos, uniq = set(), [] for r in sorted(res, key=lambda x: x["port"]): k = (r["port"], r["proc"]) if k not in vistos: vistos.add(k); uniq.append(r) return uniq def declarados(t): """Lo que el init DICE que hay. Se compara contra lo vivo — no se cree.""" fuentes = {} # `rc-status -a` lista TODO lo disponible en el sistema, no lo que esta máquina usa: en gioser # daba 100 «declarado-muerto» que no significan nada. `rc-update show` da lo ENCENDIDO por # runlevel, que es lo que de verdad se declara. out, ok = t.run("rc-update show 2>/dev/null | awk '{print $1}' | grep -E '^[a-z]'") if ok and out: fuentes["openrc"] = out.split() out, ok = t.run("systemctl list-units --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}'") if ok and out: fuentes["systemd"] = [s.replace(".service", "") for s in out.split()] out, ok = t.run("ls /etc/arje/cards.d/ 2>/dev/null") if ok and out: fuentes["arje-cards"] = [s.replace(".json", "") for s in out.split()] out, ok = t.run("python3 -c \"import json;print(' '.join(n['label'] for n in " "json.load(open('/ente/seed.card.json'))['genesis']))\" 2>/dev/null") if ok and out: fuentes["arje-seed"] = out.split() out, ok = t.run("ls /etc/service /run/runit/service 2>/dev/null") if ok and out: fuentes["runit"] = out.split() return fuentes def dominios(t): """Los dominios que la máquina DICE servir, contrastados con DNS y con un GET real. Las tres respuestas posibles importan y son distintas: sin DNS (nadie lo resuelve), con DNS pero 5xx (backend caído), y con DNS y 200 (vivo). Y hay una cuarta que sólo se ve mirando a DÓNDE resuelve: un dominio que responde 200 desde OTRA IP ya está mudado, y su bloque de config acá es un fósil.""" confs = ["/etc/caddy/Caddyfile", "/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/*", "/etc/apache2/sites-enabled/*", "/etc/httpd/conf.d/*"] out, _ = t.run("cat " + " ".join(confs) + " 2>/dev/null") nombres = set() for l in out.splitlines(): s = l.strip() if s.startswith("#"): continue # caddy: «a.com, b.com {» · nginx/apache: «server_name a.com b.com;» / «ServerName a.com» if s.endswith("{") and not s.startswith(("@", "}")): cand = s[:-1] elif s.lower().startswith(("server_name", "servername", "serveralias")): cand = s.split(None, 1)[1].rstrip(";") if len(s.split(None, 1)) > 1 else "" else: continue for d in re.split(r'[,\s]+', cand): d = d.strip().rstrip(";") if re.fullmatch(r'[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9-]+)+', d, re.I): nombres.add(d.lower()) return sorted(nombres) def sondear_dominio(d, ips_maquina): """DNS + HTTP desde donde corre el censo. Determinista, sin juicio. ⚠⚠ **NO se hace contra la máquina que uno está censando desde ella misma.** «Sólo lee» era falso: leer POR RED tiene efectos. Medido el 2026-09-11 — censar gioser en modo `--local` disparó 29 peticiones HTTPS a sus propios dominios, varios devuelven 502, y su `fail2ban` (jaula `caddy-backend-down`) **baneó la propia IP de la máquina**. Resultado: gioser dejó de poder hablar con su propio gitea y los `git push` empezaron a fallar con «Could not read from remote repository». Un censo que deja la máquina incomunicada no es de sólo lectura. Y además es metodológicamente mejor: lo que interesa es **qué ve el mundo**, no qué ve la máquina de sí misma. Por eso la sonda se hace desde donde corre el censo —el operador o la caja nueva— y se SALTA cuando se censa en local, salvo `--probe` explícito.""" ip = "" try: r = subprocess.run(["getent", "ahostsv4", d], capture_output=True, text=True, timeout=10) if r.returncode == 0 and r.stdout.strip(): ip = r.stdout.split()[0] except Exception: pass code = "000" if ip: try: r = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "8", f"https://{d}/"], capture_output=True, text=True, timeout=20) code = r.stdout.strip() or "000" except Exception: pass if not ip: clase = "fosil-sin-dns" elif ip not in ips_maquina: clase = "ya-mudado" # resuelve a otra máquina: el bloque de acá es fósil elif code.startswith(("2", "3")): clase = "vivo" else: clase = "backend-caido" return {"domain": d, "dns": ip, "http": code, "class": clase} def datos(t, raices): """Qué pesa y dónde. Sin esto no se puede dimensionar la mudanza — y `du` sobre hardlinks MIENTE para dimensionar una COPIA (medido: 60 G por `du`, 85 G al copiar sin `-H`), así que se reportan los dos números.""" res = [] for r in raices: out, ok = t.run(f"du -sh {shlex.quote(r)} 2>/dev/null | cut -f1; " f"du -sh --count-links {shlex.quote(r)} 2>/dev/null | cut -f1") ls = out.splitlines() if ok and ls: res.append({"path": r, "size": ls[0], "size_copy": ls[1] if len(ls) > 1 else ls[0]}) return res def montajes(t): out, _ = t.run("df -h 2>/dev/null | grep -vE 'tmpfs|devtmpfs|^Filesystem|^Sist' ") res = [] for l in out.splitlines(): f = l.split() if len(f) >= 6: res.append({"dev": f[0], "size": f[1], "used": f[2], "avail": f[3], "mount": f[-1]}) return res def fuera_de_git(t): """Lo que NO viaja con el repo y sin lo cual el servidor nuevo no funciona. Se reporta la EXISTENCIA, nunca el contenido: un censo no copia secretos.""" cands = ["~/.ssh", "/root/.ssh", "~/.config/hammer", "/root/.config/hammer", "/etc/caddy", "/etc/nginx", "/etc/letsencrypt", "/var/lib/gitea", "/etc/arje/cards.d", "/ente"] res = [] for c in cands: out, ok = t.run(f"ls -d {c} >/dev/null 2>&1 && ls -1 {c} 2>/dev/null | head -20") if ok: res.append({"path": c, "entries": out.split() if out else []}) out, _ = t.run("crontab -l 2>/dev/null | grep -vE '^#|^$' | wc -l") if out.isdigit() and int(out) > 0: res.append({"path": "crontab", "entries": [f"{out} líneas"]}) return res # ── clasificación ─────────────────────────────────────────────────────────────────────────────── # Nombres que NO son servicios: son el andamiaje del sistema o hijos de un servicio. Contarlos como # `no-declarado` es ruido, y un censo con hallazgos falsos se ignora entero. NO_SERVICIOS = { "agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo", "bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl", } def normalizar(n): """Un mismo servicio se llama distinto en cada sitio: OpenRC dice `act-runner`, `ps` dice `act_runner`, systemd dice `act-runner.service`, y los getty por tty son `agetty.tty1`, `agetty-tty2`… Sin normalizar, el MISMO proceso sale a la vez como `no-declarado` y como `declarado-muerto`, que son las dos clases que más importan. Medido en gioser: pasaba con `act_runner`.""" n = n.strip().lower() for suf in (".service", ".socket", ".timer", ".target"): if n.endswith(suf): n = n[: -len(suf)] n = n.replace("_", "-") n = re.sub(r'[.\-]tty\d+$', '', n) # agetty.tty1 / agetty-tty2 → agetty n = re.sub(r'\d+$', '', n) if re.fullmatch(r'[a-z-]+\d+', n) else n return n def emparenta(vivo, declarado): """¿Son el mismo servicio con distinto nombre? Los desajustes son sistemáticos, no caprichosos: el DEMONIO y la UNIDAD se llaman distinto. Medidos en gioser: `crond`↔`cronie`, `dbus-daemon`↔`dbus`, `fail2ban-server`↔`fail2ban`, `elogind-daemon`↔`elogind`. Sin esto, cada uno de ésos aparece a la vez como `no-declarado` (falso: sí está declarado) y como `declarado-muerto` (falso: sí está corriendo) — las dos clases que más importan, las dos mal.""" if vivo == declarado: return True for suf in ("-daemon", "-server", "-bin"): if vivo.endswith(suf) and vivo[: -len(suf)] == declarado: return True # `crond` → `cron`, prefijo de `cronie`. `sshd` → `ssh`. Sólo quitando UNA `d` final y exigiendo # prefijo: quitar letras a ciegas emparenta cosas que no tienen nada que ver. if len(vivo) > 4 and vivo.endswith("d") and declarado.startswith(vivo[:-1]): return True return False def es_nombre_de_servicio(n): """Descarta lo que claramente no es un servicio: versiones (`2.1.240`), rutas, vacíos. Salieron de parsear `rc-status -a`, cuya salida incluye líneas que no son unidades.""" return bool(re.fullmatch(r'[a-z][a-z0-9@._+-]{1,40}', n)) and not re.fullmatch(r'[\d.]+', n) def clasificar(vivos, decl): """Las tres clases. `no-declarado` es la importante: corre y nadie sabe cómo levantarlo.""" # índice normalizado: nombre-canónico → fuentes que lo declaran idx = {} for fuente, nombres in decl.items(): for n in nombres: if es_nombre_de_servicio(n.lower()): idx.setdefault(normalizar(n), set()).add(fuente) out, vistos, descartados = [], set(), [] for nombre, pids in sorted(vivos.items()): canon = normalizar(nombre) if canon in NO_SERVICIOS or not es_nombre_de_servicio(canon): # No se esconde: un proceso con ppid==1 que no es un servicio suele ser un HUÉRFANO, y # eso es un hallazgo (procesos fugados que retienen locks, por ejemplo). descartados.append(nombre) continue fuentes = sorted(idx.get(canon, set())) if not fuentes: # segunda pasada: demonio vs unidad emparentados = [f for d, fs in idx.items() if emparenta(canon, d) for f in fs] fuentes = sorted(set(emparentados)) if fuentes: vistos.update(d for d in idx if emparenta(canon, d)) vistos.add(canon) out.append({"name": nombre, "pids": pids, "class": "vivo" if fuentes else "no-declarado", "declared_in": fuentes}) for canon in sorted(set(idx) - vistos): if canon in NO_SERVICIOS: continue out.append({"name": canon, "pids": [], "class": "declarado-muerto", "declared_in": sorted(idx[canon])}) return out, descartados # ── salida ────────────────────────────────────────────────────────────────────────────────────── def toml_escape(s): return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"' def emitir_toml(c): L = [] L.append("# censo de mudanza — generado por scripts/mudanza/censar.py. NO editar a mano salvo") L.append("# para DECIDIR: la etapa siguiente lee `decision` de cada entrada.") L.append(f"schema = \"takana-censo/1\"") L.append(f"fecha = {toml_escape(c['fecha'])}") L.append("") L.append("[maquina]") for k, v in c["identidad"].items(): L.append(f"{k} = {toml_escape(v) if isinstance(v, str) else json.dumps(v)}") L.append("") for m in c["montajes"]: L.append("[[montaje]]") for k, v in m.items(): L.append(f"{k} = {toml_escape(v)}") L.append("") for s in c["servicios"]: L.append("[[servicio]]") L.append(f"name = {toml_escape(s['name'])}") L.append(f"class = {toml_escape(s['class'])}") L.append(f"declared_in = {json.dumps(s['declared_in'])}") L.append(f"pids = {json.dumps(s['pids'])}") L.append(f"ports = {json.dumps([p['port'] for p in c['puertos'] if p['proc'].startswith(s['name'][:15])])}") L.append('decision = "" # muda | muere | (vacío = sin decidir)') L.append("") for d in c["dominios"]: L.append("[[dominio]]") for k, v in d.items(): L.append(f"{k} = {toml_escape(v)}") L.append('decision = "" # muda | muere | (vacío = sin decidir)') L.append("") for d in c["datos"]: L.append("[[datos]]") for k, v in d.items(): L.append(f"{k} = {toml_escape(v)}") L.append('decision = "" # muda | muere | (vacío = sin decidir)') L.append("") for f in c["fuera_de_git"]: L.append("[[fuera_de_git]]") L.append(f"path = {toml_escape(f['path'])}") L.append(f"entries = {json.dumps(f['entries'])}") L.append("") return "\n".join(L) + "\n" def resumen(c): i = c["identidad"] print(f"\n══ CENSO de {i['hostname']} ({', '.join(i['ips']) or '?'}) ══") print(f" kernel {i['kernel']} · PID 1: {i['pid1']}") por_clase = {} for s in c["servicios"]: por_clase.setdefault(s["class"], []).append(s["name"]) print("\n── servicios ──") for cl in ("vivo", "no-declarado", "declarado-muerto"): n = por_clase.get(cl, []) marca = "⚠ " if cl == "no-declarado" else " " print(f"{marca}{cl:18} {len(n):3} {' '.join(sorted(n)[:8])}{' …' if len(n) > 8 else ''}") if c.get("huerfanos"): print(f" descartados (no son servicios: herramientas sueltas o HUÉRFANOS con ppid==1): " f"{len(c['huerfanos'])} {' '.join(sorted(c['huerfanos'])[:8])}") if por_clase.get("no-declarado"): print(" ⚠ `no-declarado` = corre y NADIE sabe cómo levantarlo. Es el trabajo de la mudanza.") if c["dominios"]: print("\n── dominios ──") pd = {} for d in c["dominios"]: pd.setdefault(d["class"], []).append(d["domain"]) for cl in ("vivo", "ya-mudado", "backend-caido", "fosil-sin-dns", "sin-sondear"): n = pd.get(cl, []) if n: print(f" {cl:16} {len(n):3} {' '.join(sorted(n)[:6])}{' …' if len(n) > 6 else ''}") if c["datos"]: print("\n── datos ──") for d in c["datos"]: extra = f" (copiar sin -H: {d['size_copy']})" if d["size_copy"] != d["size"] else "" print(f" {d['path']:28} {d['size']:>7}{extra}") print() def main(): ap = argparse.ArgumentParser(description="Censo de una máquina para mudarla. Sólo lee.") ap.add_argument("--host", help="usuario@host a censar por SSH") ap.add_argument("--key", help="clave SSH") ap.add_argument("--local", action="store_true", help="censar esta máquina") ap.add_argument("--out", help="fichero TOML de salida (default: stdout sólo el resumen)") ap.add_argument("--probe", action="store_true", help="sondear los dominios por DNS+HTTP. Por defecto SÓLO con --host: hacerlo " "contra uno mismo puede disparar el fail2ban de la propia máquina") ap.add_argument("--no-probe", action="store_true", help="no sondear dominios") ap.add_argument("--data-root", action="append", default=[], help="raíz de datos a medir (repetible). Default: las habituales") a = ap.parse_args() if not a.host and not a.local: ap.error("hace falta --host o --local") t = Objetivo(a.host, a.key) print(" censando… (no se ESCRIBE nada; las sondas de red sí dejan huella: ver --probe)", file=sys.stderr) ident = identidad(t) vivos = procesos_vivos(t) decl = declarados(t) svc, huerfanos = clasificar(vivos, decl) pts = puertos(t) nombres = dominios(t) sondear = (a.probe or bool(a.host)) and not a.no_probe if nombres and not sondear: print(f" ⚠ {len(nombres)} dominio(s) declarados, SIN sondear: censando en local, y pedirle", file=sys.stderr) print(" a la máquina sus propios dominios puede disparar su fail2ban y dejarla", file=sys.stderr) print(" incomunicada (pasó). Sondealos desde otra máquina, o forzá con --probe.", file=sys.stderr) doms = [{"domain": d, "dns": "", "http": "", "class": "sin-sondear"} for d in nombres] else: doms = [] for i, d in enumerate(nombres): if i: time.sleep(0.5) # no parecer un escaneo ante el propio fail2ban del objetivo doms.append(sondear_dominio(d, set(ident["ips"]))) raices = a.data_root or ["/var/www", "/var/lib", "/srv", "/opt", "/home", "/store"] dat = datos(t, raices) censo = { "fecha": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "identidad": ident, "servicios": svc, "huerfanos": huerfanos, "puertos": pts, "dominios": doms, "datos": dat, "montajes": montajes(t), "fuera_de_git": fuera_de_git(t), } resumen(censo) if a.out: with open(a.out, "w") as f: f.write(emitir_toml(censo)) print(f" censo → {a.out} (decidí `decision` en cada entrada y pasalo a la etapa 2)") if __name__ == "__main__": main()