Files
takana/scripts/mudanza/censar.py
T
SergioandClaude Opus 5 1a89592b72 mudanza etapa 3: declarar.py — de «corre y nadie sabe cómo» a una Semilla que arranca
Copiar un binario no lo levanta al arrancar. Esto toma la invocación que el censo leyó de `/proc` y
emite `seed.card.json`. Medido sobre gioser: **37 de 37 servicios vivos declarados, cero fallos**, y
la tarjeta de caddy sale con su invocación real — exactamente lo que faltaba para que no muriera en
el próximo reinicio.

Por qué no alcanza con `arje-absorb`: absorb lee la DECLARACIÓN del init ajeno, que es justo la que
miente (`rc-status` daba `stopped` para cinco servicios vivos), y los `no-declarado` no aparecen en
ninguna declaración por definición. Absorber la declaración reproduce el agujero. Se complementan.

Tres reglas:
1. Un servicio sin `cmdline` legible NO se emite y se dice: una tarjeta que no arranca es peor que
   una ausente — la ausente falla ruidosamente, la rota deja el servicio caído en silencio.
2. El `cwd` se preserva ENVOLVIENDO, porque el payload `Native` acepta `exec`/`argv`/`envp` y no
   `cwd` (comprobado sobre la semilla real). 9 de 37 servicios de gioser dependen de su directorio.
3. IDs deterministas ⇒ misma entrada, misma semilla BYTE A BYTE (verificado con sha256). Un fichero
   generado que cambia en cada corrida no se puede revisar con `diff`.

**Y un bug del censo que costaba el 70 % del dato**: `cmdline` y `cwd` se leían en un solo comando, y
como `readlink /proc/<pid>/cwd` exige permiso de ptrace, en cualquier proceso de root el comando
entero salía ≠0 y se descartaba TAMBIÉN el `cmdline`. 26 de 37 servicios quedaban sin invocación.
Sondas separadas. **Una sonda que falla es un dato; no puede arrastrar a las que funcionaron.**

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
2026-09-11 15:13:53 +00:00

511 lines
25 KiB
Python
Executable File

#!/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 invocaciones(t, pids):
"""La LÍNEA DE COMANDO y el `cwd` de cada servicio vivo, leídos de `/proc`.
Es el dato que convierte `no-declarado` de «acá hay un problema» en «acá está la solución»: si
nadie sabe cómo levantar un servicio, lo que hace falta para declararlo es exactamente cómo se
está ejecutando ahora. Sin esto el plan sólo puede decir «declaralo», que es lo que uno ya sabía.
⚠ Se lee `cmdline` y `cwd`, NO el entorno: `/proc/<pid>/environ` suele traer tokens y
contraseñas, y un censo que se guarda en un fichero y se manda por ahí no puede llevarlos."""
out = {}
for nombre, ps in pids.items():
pid = ps[0]
# ⚠ SONDAS SEPARADAS, y es un bug pagado: iban en un solo comando, y como `readlink
# /proc/<pid>/cwd` necesita permiso de ptrace (dueño o root), en cualquier proceso de root
# el comando entero salía ≠0 y se descartaba TAMBIÉN el `cmdline`, que sí se podía leer.
# Resultado: 26 de 37 servicios sin invocación, justo el dato que da sentido al censo.
# Una sonda que falla es un dato; no puede arrastrarse a las que funcionaron.
cmd, _ = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null")
cwd, _ = t.run(f"readlink /proc/{pid}/cwd 2>/dev/null")
cmd, cwd = cmd.strip(), cwd.strip()
if cmd.startswith("(sonda falló"):
cmd = ""
if cwd.startswith("(sonda falló"):
cwd = ""
if cmd or cwd:
out[nombre] = {"cmdline": cmd, "cwd": cwd}
return out
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(f"cmdline = {toml_escape(s.get('cmdline',''))}")
L.append(f"cwd = {toml_escape(s.get('cwd',''))}")
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)
inv = invocaciones(t, vivos)
for e in svc:
i = inv.get(e['name'], {})
e['cmdline'] = i.get('cmdline', '')
e['cwd'] = i.get('cwd', '')
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()