> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
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
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