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:
+128
-9
@@ -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"]:
|
||||
|
||||
Reference in New Issue
Block a user