censar: el home de la PERSONA no se miraba nunca — y ahí está la clave privada de release

`fuera_de_git()` era una lista cableada de 10 rutas con `~` adentro, y el censo corre como root:
`~` se expandía a `/root`. La huella quedó en `work/mudanza/censo-gioser.toml`, donde `~/.ssh` y
`/root/.ssh` salen IDÉNTICOS. Lo que nunca se censó por su nombre:

  /home/sergio/.config/takana   ⚠ la clave PRIVADA de release (el .pub está EN el repo)
  /home/sergio/.config/gh          el token con el que se crean los espejos de los 26 repos
  /home/sergio/.ssh/config         el bloque `git.gioser.net` `Port 2345`, sin el cual no se clona
  /home/sergio/.gitconfig          los `insteadOf`, que reescriben remotos y esconden que uno es local
  /home/sergio/.claude      6,8 G  la memoria y los transcripts del proyecto

Ninguna de esas falla el día que se borra la máquina: la clave falla la próxima vez que alguien
firma, y para entonces no hay de dónde sacarla. Hoy sólo viajaban dentro del bloque `/home` (22 G,
`destino = ""`), o sea sin que nadie las hubiera mirado.

Cuatro cambios:

· **La lista sale a `rutas-fuera-de-git.txt`**, un fichero de datos con el motivo de cada ruta. Una
  lista cableada se queda vieja sin que nada falle: la anterior preguntaba por `~/.config/hammer`
  —muerto desde el renombre, existe VACÍO— y no preguntaba por `~/.config/takana`.
· **`homes()` lee `/etc/passwd`** y expande `~/…` por cada home real (root incluido, cuentas de
  servicio con `nologin` fuera). En gioser: 6 homes, 19 entradas contra las 9 de antes.
· **Tres estados, no dos.** Un `ls` sin permiso se lee igual que un directorio ausente: `ausente` se
  descarta, `sin_permiso` se REPORTA («2 rutas EXISTEN y no pude leerlas») para que nadie decida
  sobre una lista incompleta creyéndola completa. Probado corriendo el censo como no-root.
· **Una sola llamada** en vez de una por ruta: N rutas × M homes por SSH era el censo tardando más
  que el trabajo que describe. Y cada entrada lleva dueño, tamaño y por qué importa.

Y el paso que faltaba en `planear.py`: `fuera_de_git` no lo consumía NADIE, así que el censo lo
listaba y el plan no proponía copiarlo — se veía y no se actuaba. Ahora es el paso 0 bis, pegado al
del código, con revisión humana para decidir y, por cada ruta `muda`, su `rsync -aHAX --numeric-ids`
(permisos: una llave con el modo cambiado no falla al copiarse, falla al usarse) verificado POR
CONTENIDO: el digest de los digests, que no revela nada y caza lo que contar ficheros no caza — una
llave truncada cuenta como un fichero igual que la entera. Probado en los dos sentidos: idéntico ⇒
0, un byte distinto ⇒ 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Tf9T4vGzsMoT7eS8YzMFn
This commit is contained in:
Sergio
2026-09-14 14:38:56 +00:00
co-authored by Claude Opus 5
parent 589efee4f8
commit 76535f8e42
3 changed files with 214 additions and 9 deletions
+128 -9
View File
@@ -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"]: