tawasuyu

sergio/takana

> 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…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

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

76535f8e42a4c4d1bd828bc92ef8592e890e0057 · by Sergio · (1 month ago) · parent 589efee4f · browse the tree at this commit

Semantic delta · 6

scripts/mudanza/censar.py

scripts/mudanza/planear.py

3 files · +214 −9

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