diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py index 2aed29c8..f4b43960 100755 --- a/scripts/mudanza/censar.py +++ b/scripts/mudanza/censar.py @@ -533,20 +533,117 @@ def montajes(t): return res +def homes(t): + """Los homes REALES de la máquina, leídos de `/etc/passwd`. Root siempre; los demás sólo si su + shell no es `nologin`/`false` (un home de cuenta de servicio no guarda config de nadie). + + ── POR QUÉ NO ALCANZA CON `~` ────────────────────────────────────────────────────────────── + El censo corre por SSH como root, así que `~` se expande a `/root` y el home de la PERSONA no se + mira nunca. En el censo de gioser la huella era que `~/.ssh` y `/root/.ssh` salían idénticos, y + lo que quedó sin censar fue la clave privada de release, el token de `gh` y 6,8 G de `~/.claude`. + Nada de eso falla el día que se borra la máquina: falla la próxima vez que alguien firma. + """ + out, _ = t.run("getent passwd 2>/dev/null || cat /etc/passwd 2>/dev/null") + res, vistos = [], set() + for linea in out.splitlines(): + f = linea.split(":") + if len(f) < 7: + continue + user, home, shell = f[0], f[5], f[6] + if not home or home in ("/", "/nonexistent", "/dev/null", "/bin", "/sbin"): + continue + if user != "root" and shell.rsplit("/", 1)[-1] in ("nologin", "false", "sync", ""): + continue + if home in vistos: + continue + vistos.add(home) + res.append({"user": user, "home": home}) + return res + + +def _rutas_candidatas(): + """Lee `rutas-fuera-de-git.txt` (al lado de este guión). Devuelve [(ruta, motivo)]. + + La lista vive en un fichero de DATOS y no en un literal de Python porque una lista cableada se + queda vieja sin que nada falle: la anterior preguntaba por `~/.config/hammer`, muerto desde el + renombre, y no preguntaba por `~/.config/takana`, que es donde está la clave de firma. + """ + ruta = os.path.join(os.path.dirname(os.path.abspath(__file__)), "rutas-fuera-de-git.txt") + out = [] + with open(ruta, encoding="utf-8") as fh: + for linea in fh: + linea = linea.strip() + if not linea or linea.startswith("#"): + continue + p, _, motivo = linea.partition("#") + p = p.strip() + if p: + out.append((p, motivo.strip())) + return out + + 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"] + EXISTENCIA y los nombres de entrada, nunca el contenido: un censo no copia secretos. + + ── TRES ESTADOS, NO DOS ──────────────────────────────────────────────────────────────────── + `existe` / `no existe` no alcanza: si el censo corre sin privilegio para leer el home de otro, + un `ls` que falla se lee igual que un directorio ausente y el resultado es un censo que dice + «no hay nada» de lo que no pudo mirar. Por eso hay `sin_permiso`, que es un dato distinto y va + al resumen: lo ausente se descarta, lo no mirado se vuelve a mirar con quien pueda. + + Todo en UNA sola llamada (un script generado) y no una por ruta: por SSH, N rutas × M homes son + decenas de conexiones y el censo tarda más que el trabajo que describe. + """ + cands = _rutas_candidatas() + hs = homes(t) + objetivos = [] # (ruta_absoluta, motivo, dueño) + for p, motivo in cands: + if p.startswith("~/"): + for h in hs: + objetivos.append((os.path.join(h["home"], p[2:]), motivo, h["user"])) + else: + objetivos.append((p, motivo, "")) + + prog = ["set -u"] + for ruta, _, _ in objetivos: + q = shlex.quote(ruta) + prog.append( + f'p={q}; ' + 'if [ ! -e "$p" ]; then st=ausente; ' + 'elif [ -f "$p" ]; then st=fichero; ' + 'elif ls -1 "$p" >/dev/null 2>&1; then st=ok; ' + 'else st=sin_permiso; fi; ' + 'sz=$(timeout 20 du -shx "$p" 2>/dev/null | cut -f1); ' + 'printf "P\t%s\t%s\t%s\n" "$p" "$st" "${sz:-?}"; ' + '[ "$st" = ok ] && ls -1 "$p" 2>/dev/null | head -20 | sed "s/^/E\t/"; ' + 'true' + ) + out, _ = t.run("\n".join(prog)) + + por_ruta = {} + actual = None + for linea in out.splitlines(): + campos = linea.split("\t") + if campos[0] == "P" and len(campos) >= 4: + actual = {"path": campos[1], "estado": campos[2], "size": campos[3], "entries": []} + por_ruta[campos[1]] = actual + elif campos[0] == "E" and actual is not None and len(campos) >= 2: + actual["entries"].append(campos[1]) + 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 []}) + for ruta, motivo, duenio in objetivos: + r = por_ruta.get(ruta) + if not r or r["estado"] == "ausente": + continue # lo que no está no se reporta; lo que no se pudo mirar SÍ (abajo) + res.append({"path": ruta, "owner": duenio, "porque": motivo, + "estado": r["estado"], "size": r["size"], "entries": r["entries"]}) + 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"]}) + res.append({"path": "crontab", "owner": "", "porque": "el latido: lo que esta máquina " + "dispara sola y nadie recuerda", "estado": "ok", "size": "-", + "entries": [f"{out} líneas"]}) return res @@ -774,7 +871,15 @@ def emitir_toml(c): for f in c["fuera_de_git"]: L.append("[[fuera_de_git]]") L.append(f"path = {toml_escape(f['path'])}") + if f.get("owner"): + L.append(f"owner = {toml_escape(f['owner'])}") + L.append(f"estado = {toml_escape(f.get('estado', 'ok'))}") + L.append(f"size = {toml_escape(f.get('size', '?'))}") + if f.get("porque"): + L.append(f"porque = {toml_escape(f['porque'])}") L.append(f"entries = {json.dumps(f['entries'])}") + L.append('destino = "" # vacío = la MISMA ruta en el destino') + L.append('decision = "" # muda | muere | (vacío = sin decidir)') L.append("") return "\n".join(L) + "\n" @@ -805,6 +910,20 @@ def resumen(c): n = pd.get(cl, []) if n: print(f" {cl:16} {len(n):3} {' '.join(sorted(n)[:6])}{' …' if len(n) > 6 else ''}") + fg = c.get("fuera_de_git", []) + if fg: + print("\n── fuera de git (config y secretos que no viajan con ningún repo) ──") + for f in sorted(fg, key=lambda x: (x.get("owner", ""), x["path"])): + duenio = f" [{f['owner']}]" if f.get("owner") else "" + print(f" {f['path']:42}{duenio:10} {f.get('size', '?'):>7} {f.get('porque', '')[:52]}") + ciegos = [f for f in fg if f.get("estado") == "sin_permiso"] + if ciegos: + # Un `ls` sin permiso se lee IGUAL que un directorio ausente. Decirlo es la diferencia + # entre «no hay nada» y «no pude mirar», y sólo una de las dos se puede descartar. + print(f" ⚠ {len(ciegos)} ruta(s) EXISTEN y el censo no pudo leerlas " + f"(corré el censo como su dueño o con privilegio):") + for f in ciegos: + print(f" {f['path']}") if c["datos"]: print("\n── datos ──") for d in c["datos"]: diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py index d9d60edf..fbfac008 100755 --- a/scripts/mudanza/planear.py +++ b/scripts/mudanza/planear.py @@ -662,6 +662,62 @@ def pasos(censo, target, key): "binarios que nadie más provee, y su único remoto es el gitea de esta misma " "máquina. Sin esto, apagar el origen deja servicios que no se pueden reconstruir.") + # ── 0 bis. LO QUE NO VIAJA CON NINGÚN REPO ────────────────────────────────────────────────── + # El paso 1 salva el CÓDIGO; éste salva lo que hace que ese código sirva de algo: las llaves, los + # certificados, los tokens y la configuración que no está versionada en ninguna parte. Va acá + # arriba por la misma razón —se pierde para siempre— y con una diferencia que importa: un repo + # sin copia fuera se nota al intentar clonarlo, y una clave privada borrada no se nota hasta la + # próxima vez que alguien firma. La peor de todas es `~/.config/takana/keys/release.ed25519`: el + # `.pub` vive EN el repo, la privada sólo en esta máquina. + fg = [f for f in censo.get("fuera_de_git", []) if f.get("path") != "crontab"] + if fg: + ciegos = [f for f in fg if f.get("estado") == "sin_permiso"] + det = ["# Config, llaves y credenciales que no están en ningún repo. Se listan por su ruta,", + "# su DUEÑO y su tamaño; el censo nunca leyó su contenido.", "#"] + for f in sorted(fg, key=lambda x: (x.get("owner", ""), x["path"])): + duenio = f" [{f['owner']}]" if f.get("owner") else "" + det.append(f"# {f['path']}{duenio} ({f.get('size', '?')})") + if f.get("porque"): + det.append(f"# {f['porque']}") + nota = ("⚠ `~` NO es un home: el censo corre como root y sólo mira `/root` salvo que se le " + "pidan los homes de `/etc/passwd`. Lo que se censa por su nombre se decide; lo que " + "sólo viaja dentro del bloque `/home` viaja sin que nadie lo haya mirado.") + if ciegos: + # Un `ls` sin permiso se lee IGUAL que un directorio ausente: decir cuál de las dos es + # la única forma de que «no hay nada ahí» sea una afirmación y no un silencio. + nota += (" ⚠⚠ " + str(len(ciegos)) + " de estas rutas EXISTEN y el censo NO pudo leerlas " + "(" + ", ".join(f["path"] for f in ciegos[:4]) + "): volvé a censar como su " + "dueño antes de decidir, o vas a decidir sobre una lista incompleta.") + paso("secretos", f"{len(fg)} ruta(s) de config y credenciales fuera de todo repo", + "\n".join(det), + verifica="revisión humana: por cada una, decidir `muda` o `muere` — y para las que " + "mudan, que el destino la tenga ANTES de apagar nada", + verifica_tipo="humano", nota=nota) + + for f in fg: + if f.get("decision") != "muda": + continue + origen = f["path"] + dst = f.get("destino") or origen + # `-aHAX --numeric-ids`: permisos, ACLs y xattrs. Una llave que llega con el modo + # cambiado no falla al copiarse, falla al usarse («permissions are too open»), que es + # varios pasos más tarde y en otro sitio. + paso("secretos", f"copiar {origen} → {dst} ({f.get('size', '?')})", + f"rsync -aHAX --numeric-ids --partial " + f"-e {shlex.quote('ssh ' + ssh_opts)} {shlex.quote(origen)} " + f"{target}:{shlex.quote(os.path.dirname(dst.rstrip('/')) or '/')}/", + # Verificación POR CONTENIDO sin revelar contenido: el digest de los digests. + # Contar ficheros no alcanza — una llave truncada cuenta igual que una entera. + verifica=(f"h() {{ find \"$1\" -type f -exec sha256sum {{}} + 2>/dev/null " + f"| awk '{{print $1}}' | sort | sha256sum | cut -c1-16; }}; " + f"src=$(h {shlex.quote(origen)}); " + f"dst=$({ssh} \"" + f"find {shlex.quote(dst)} -type f -exec sha256sum {{}} + 2>/dev/null " + f"| awk '{{print \\$1}}' | sort | sha256sum | cut -c1-16\"); " + f'echo "digest: origen=$src destino=$dst"; [ "$src" = "$dst" ]'), + nota="El digest es de los CONTENIDOS, no de los nombres ni de la cuenta: una llave " + "truncada a la mitad cuenta como un fichero igual que la entera.") + # ── 0 ter. COHERENCIA ─────────────────────────────────────────────────────────────────────── incoh = incoherencias(censo) if incoh: diff --git a/scripts/mudanza/rutas-fuera-de-git.txt b/scripts/mudanza/rutas-fuera-de-git.txt new file mode 100644 index 00000000..fc456d8d --- /dev/null +++ b/scripts/mudanza/rutas-fuera-de-git.txt @@ -0,0 +1,30 @@ +# Rutas que NO viajan con ningún repo y sin las cuales la máquina nueva no funciona. +# +# Estaban cableadas dentro de `censar.py` y eso costó dos agujeros (medidos 2026-09-14): +# +# 1. La lista traía `~/.config/hammer`, ruta muerta desde el renombre (ADR 0016). Existe vacía; +# lo vivo es `~/.config/takana`, que es donde está la CLAVE PRIVADA de release. El renombre no +# rompió nada: dejó de mirar. +# 2. El censo corre como root, así que `~` era `/root` y el home de la persona no se miraba nunca +# — la huella en el censo viejo es que `~/.ssh` y `/root/.ssh` salen IDÉNTICOS. +# +# Formato: `ruta # por qué importa`. Una ruta que empieza con `~/` se expande por CADA home real de +# `/etc/passwd` (root incluido); una ruta absoluta se mira una sola vez. El motivo no es adorno: se +# emite al censo y al plan, porque «copiá ~/.config/takana» sin decir qué hay dentro no se prioriza. +# +# Se reporta EXISTENCIA y nombres de entrada, nunca contenido: un censo no copia secretos. + +~/.ssh # llaves privadas, `authorized_keys` y `known_hosts`: el acceso a y desde esta máquina +~/.ssh/config # los alias de host — el bloque `git.gioser.net` con `Port 2345` sin el cual no se clona del gitea +~/.config/takana # ⚠ la CLAVE PRIVADA de release (`keys/release.ed25519`). El `.pub` está en el repo; esto NO +~/.config/gh # el token de GitHub: el que crea y empuja los espejos de los repos que sólo existen acá +~/.gitconfig # identidad de commit y los `insteadOf`, que REESCRIBEN remotos y pueden esconder que un repo es local +~/.npmrc # tokens de registries privados +~/.claude # la memoria y los transcripts del proyecto — no es config, es trabajo acumulado +~/.local/bin # binarios instalados a mano que ningún paquete provee +/etc/caddy # la configuración del servidor web: los vhosts, que son el mapa de lo que esta máquina sirve +/etc/nginx # ídem, si quedó algo del servidor anterior +/etc/letsencrypt # certificados y cuentas ACME +/var/lib/gitea # los repos servidos, la sqlite y las claves del gitea +/etc/arje/cards.d # las tarjetas de arje: qué arranca esta máquina +/ente # la Semilla de arje-zero