`planear.py` convierte un censo decidido en un plan ejecutable. Cada paso lleva su COMANDO LITERAL y
su verificación, así que el fichero se ejecuta a mano, línea por línea, sin la herramienta y sin este
repo. Eso es lo que el usuario pidió como «pasos exportables».
Las tres reglas, cada una pagada en el SDD 28:
1. **Nada sin decidir se ejecuta**: aborta con código 2 listando qué falta. El silencio no es
consentimiento — sin decisión, ni mudar ni matar es correcto. Probado en los dos sentidos.
2. **Lo que muere se dice por su nombre y con su tamaño ANTES de borrar**, y el paso no borra nada:
es una lista para leer antes de apagar el origen.
3. **Cada copia se verifica EN DESTINO**: el `rsync` que llenó el disco devolvió 0 y dejó 1367
artefactos vacíos; sólo se vio contando del otro lado.
Y el preflight dimensiona con el tamaño de COPIA (hardlinks expandidos, 60 G → 85 G medidos), no con
`du`; si un tamaño resulta ilegible lo dice en vez de contarlo como 0.
**El añadido que cambia el valor: la invocación real.** Decir «este servicio no está declarado»
nombra el problema; lo que hace falta para resolverlo es cómo corre AHORA. El censo lo lee de
`/proc` (`cmdline` + `cwd`, nunca `environ`: el entorno trae tokens) y el plan lo emite:
# cmdline: /mnt/vvv/tawasuyu/target/debug/deps/puerta-f6e393ffbef3999e
# cwd : /mnt/vvv/tawasuyu/shared/tejido
# puertos: 34221, 44961
Ese servicio de gioser es un binario de `target/debug/deps/` corriendo en producción, con dos
puertos, que ninguna declaración conoce. Apagada la máquina vieja, eso no se reconstruye de memoria.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x
503 lines
24 KiB
Python
Executable File
503 lines
24 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]
|
|
r, ok = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null; echo; "
|
|
f"readlink /proc/{pid}/cwd 2>/dev/null")
|
|
if ok:
|
|
ls = r.split("\n")
|
|
out[nombre] = {"cmdline": (ls[0] if ls else "").strip(),
|
|
"cwd": (ls[1] if len(ls) > 1 else "").strip()}
|
|
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()
|