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 1569Shell 203Rust 109Markdown 993728 Definiciones

censar: tres nombres equivocados que llegaban al plan, y los 7 «binario desconocido» eran permisos

El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de
CLASIFICACIÓN — y no son cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/<n>` y el
que va de `label` en la tarjeta de arje.

· `comm` está capado a 15 caracteres: `willay-crosscheck` llegaba como `willay-crossche` y con ese
  nombre no casaba contra su declaración ⇒ figuraba como `no-declarado` TENIENDO su tarjeta en la
  semilla de arje. La línea de comando trae el nombre entero.
· `supervise-daemon` no es un servicio, es un ENVOLTORIO. Los cinco de gioser se fundían en una
  entrada `supervise-daemo`; y del otro lado `dbus`, `metalog`, `dhcpcd`, `squid` y `shuma-daemon`
  salían como `declarado-muerto` ESTANDO VIVOS — el mismo agujero que este censo existe para tapar,
  entrando por la otra puerta. El nombre real es su argv[1] y el comando real es el último token
  antes del `--` suelto (comprobado contra los cinco).
· `head -15` con ppid==1 era un resto de tubería reparentado, contado como servicio. Va a
  `descartados`, que se imprimen: un huérfano es un hallazgo, no basura.

**Y los 7 «no se pudo leer su binario» eran dos cosas distintas.** Muchos demonios reescriben su
`argv[0]` (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de
comando no dice cuál es el binario — `/proc/<pid>/exe` sí, y necesita ser dueño o root. Medido:

    uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
    root     : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14

Ahora el censo DICE cuál de las dos pasó: «repetilo con sudo» y «averigualo a mano» son trabajos muy
distintos, y confundirlos manda a alguien a investigar un permiso.

Desenvolver al supervisor arregló además dos datos que salían falsos: el `exec` de `squid` era
`/usr/bin/supervise-daemon` (⇒ figuraba provisto por el paquete `openrc`), y el `cmdline` guardado
era el del SUPERVISOR — una tarjeta hecha con eso arrancaría `supervise-daemon` dentro de arje, que
ya supervisa. Y se rescata el `--user`: `shuma-daemon` corre como `sergio`, dato que la tarjeta de
arje no puede guardar (no tiene campo de usuario), así que ahora se avisa EN LA REVISIÓN — sin eso a
la vista, un servicio que acá corre sin privilegios termina de root en el destino.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RomoxEGZUhaT4pob1QSX5x

2fa89ce3fa233fdcc4be348d011986f78b03b5de · por Sergio · (hace 1 mes) · padre 2201fac08 · ver el árbol en este commit

Delta semántico · 10

scripts/mudanza/censar.py

scripts/mudanza/planear.py

3 archivos · +178 −15

diff --git a/docs/29-mudanza.md b/docs/29-mudanza.md
index 3ef26526..2e9186a7 100644
--- a/docs/29-mudanza.md
+++ b/docs/29-mudanza.md
@@ -313,6 +313,42 @@ porque `hostname` dice `momento`:
   declarado-muerto   85                 fosil-sin-dns   13
 ```
 
+#### Tres nombres equivocados, y los tres llegaban al plan
+
+El censo nombraba los servicios por `comm`, que viene del kernel. De ahí salieron tres errores de
+clasificación —no cosméticos: ese nombre es el que el plan mete en `--in /etc/init.d/<nombre>` y el
+que va de `label` en la tarjeta de arje.
+
+- **`comm` está capado a 15 caracteres.** `willay-crosscheck` llegaba como `willay-crossche`, y con
+  ese nombre no casaba contra su declaración ⇒ figuraba como **`no-declarado`** teniendo su tarjeta
+  en la semilla de arje. La línea de comando trae el nombre entero.
+- **`supervise-daemon` no es un servicio, es un envoltorio.** Los cinco de gioser se fundían en UNA
+  entrada llamada `supervise-daemo`; y del otro lado, `dbus`, `metalog`, `dhcpcd`, `squid` y
+  `shuma-daemon` salían como **`declarado-muerto` estando vivos** — el mismo agujero que el censo
+  existe para tapar, entrando por la otra puerta. El nombre real es su `argv[1]`; el **comando** real
+  es el último token antes del `--` suelto.
+- **`head -15` con `ppid==1`** era un resto de tubería reparentado, contado como servicio
+  `no-declarado`. Va a `descartados`, que se imprimen: un huérfano es un hallazgo, no basura.
+
+#### «No se pudo leer su binario» eran dos cosas distintas
+
+7 de 38 servicios salían `desconocido`. La causa no era una: muchos demonios **reescriben su
+`argv[0]`** (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la línea de
+comando no dice cuál es el binario — pero `/proc/<pid>/exe` sí, y necesita ser dueño o root. Con el
+censo corriendo como root, los 7 se resuelven:
+
+```
+  uid 1001 : desconocido 7 · paquete-ajeno 13 · receta-takana 6 · suelto 13
+  root     : desconocido 0 · paquete-ajeno 20 · receta-takana 5 · suelto 14
+```
+
+Así que el censo ahora **dice cuál de las dos cosas pasó**: «repetilo con sudo» y «averigualo a mano»
+son trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
+
+Y desenvolver al supervisor destapó un dato que se estaba perdiendo: `shuma-daemon` corre con
+`--user sergio`. La tarjeta de arje **no tiene campo de usuario**, así que sin eso a la vista un
+servicio que acá corre sin privilegios termina de root en el destino. Ahora sale en la revisión.
+
 #### El HTTP toca al origen; el DNS no
 
 Sondear los dominios desde la propia máquina disparó su `fail2ban` y la dejó incomunicada — por eso
diff --git a/scripts/mudanza/censar.py b/scripts/mudanza/censar.py
index 1ced5c69..b9f7eb7e 100755
--- a/scripts/mudanza/censar.py
+++ b/scripts/mudanza/censar.py
@@ -94,13 +94,24 @@ def invocaciones(t, pids):
         # Una sonda que falla es un dato; no puede arrastrarse a las que funcionaron.
         cmd, _ = t.run(f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null")
         cwd, _ = t.run(f"readlink /proc/{pid}/cwd 2>/dev/null")
-        cmd, cwd = cmd.strip(), cwd.strip()
-        if cmd.startswith("(sonda falló"):
-            cmd = ""
-        if cwd.startswith("(sonda falló"):
-            cwd = ""
-        if cmd or cwd:
-            out[nombre] = {"cmdline": cmd, "cwd": cwd}
+        # ⚠ Tercera sonda, y también separada. Muchos demonios REESCRIBEN su `argv[0]` para decir en
+        # qué andan (`sshd: /usr/bin/sshd [listener]`, `php-fpm: master process (…)`), así que la
+        # línea de comando NO dice cuál es el binario. `/proc/<pid>/exe` sí — pero necesita permiso
+        # de ptrace, o sea ser el dueño o root. Medido en gioser con uid 1001: 7 de 38 servicios
+        # quedaban en «no se pudo leer su binario» por esto, y no por ser realmente desconocidos.
+        exe, _ = t.run(f"readlink /proc/{pid}/exe 2>/dev/null")
+        cmd, cwd, exe = cmd.strip(), cwd.strip(), exe.strip()
+        for v in ("cmd", "cwd", "exe"):
+            if locals()[v].startswith("(sonda falló"):
+                cmd, cwd, exe = (("" if v == "cmd" else cmd), ("" if v == "cwd" else cwd),
+                                 ("" if v == "exe" else exe))
+        sup = SUPERVISADOS.get(pid)
+        if sup:
+            cmd, exe = sup["cmdline"], sup["exec"]
+        if cmd or cwd or exe:
+            out[nombre] = {"cmdline": cmd, "cwd": cwd,
+                           "exe": exe if exe.startswith("/") else "",
+                           "usuario": (sup or {}).get("usuario", "")}
     return out
 
 
@@ -136,14 +147,98 @@ def duenos(t, ejecutables):
     return res
 
 
+# Envoltorios que NO son el servicio: el nombre de verdad está en sus argumentos. Sin desenvolverlos,
+# los CINCO `supervise-daemon` de gioser se juntaban en UNA entrada llamada `supervise-daemo` — cinco
+# servicios distintos (shuma-daemon, dbus, metalog…) colapsados en una tarjeta con el nombre del
+# envoltorio. Un servicio de más es ruido; cinco servicios fundidos en uno es un plan que no arranca.
+SUPERVISORES = {"supervise-daemon", "start-stop-daemon", "s6-supervise", "runsv", "daemonize"}
+_COMM_SUP = {s[:15] for s in SUPERVISORES}
+
+# pid → lo que el supervisor manda de verdad. Se llena al listar los procesos y se consulta al leer
+# `/proc`: el pid con ppid==1 es el SUPERVISOR, así que su `cmdline` y su `exe` describen al
+# envoltorio, no al servicio.
+SUPERVISADOS = {}
+
+
+def _mandado_por_supervisor(args):
+    """El comando REAL que hay detrás de `supervise-daemon`, y con qué usuario corre.
+
+    El formato es `supervise-daemon <servicio> --start [opciones] <ejecutable> -- [argumentos]`, así
+    que el ejecutable es el ÚLTIMO token antes del `--` suelto. Comprobado contra los cinco de gioser
+    (dbus, dhcpcd, metalog, squid, shuma-daemon).
+
+    ⚠ Sin esto, el censo guardaba como `cmdline` la línea del SUPERVISOR — y una tarjeta de arje
+    hecha con eso arrancaría `supervise-daemon` DENTRO de arje, que ya supervisa. Y el `exec` salía
+    `/usr/bin/supervise-daemon`, o sea que `squid` figuraba como provisto por el paquete `openrc`.
+
+    ⚠⚠ Y se rescata el `--user`: `shuma-daemon` corre como `sergio`. Perder ese dato al desenvolver
+    sería la misma elisión que el escritor de arje reporta como SIN-TRADUCIR — un servicio que en el
+    origen corría sin privilegios terminaría de root en el destino, y nadie se enteraría."""
+    p = args.split()
+    if "--start" not in p:
+        return "", "", ""
+    resto = p[p.index("--start") + 1:]
+    usuario = ""
+    for i, x in enumerate(resto):
+        if x in ("--user", "-u") and i + 1 < len(resto):
+            usuario = resto[i + 1]
+            break
+    corte = resto.index("--") if "--" in resto else len(resto)
+    izq, der = resto[:corte], resto[corte + 1:]
+    if not izq or not izq[-1].startswith("/"):
+        return "", "", usuario
+    return izq[-1], " ".join([izq[-1]] + der), usuario
+
+
+def _desenvolver(comm, args):
+    """(nombre_real, args_reales) para un proceso que puede ser un envoltorio."""
+    p = args.split()
+    if not p:
+        return comm, args
+    base = os.path.basename(p[0])
+    if base not in SUPERVISORES and comm not in {s[:15] for s in SUPERVISORES}:
+        return comm, args
+    if base in ("supervise-daemon", "s6-supervise", "runsv") and len(p) > 1:
+        # `supervise-daemon <servicio> --start …` · `runsv /etc/service/<servicio>`
+        return os.path.basename(p[1].rstrip("/")), args
+    for i, x in enumerate(p):                      # start-stop-daemon
+        if x in ("--name", "-n") and i + 1 < len(p):
+            return p[i + 1], args
+        if x in ("--exec", "-x", "--startas", "-a") and i + 1 < len(p):
+            return os.path.basename(p[i + 1]), args
+    return comm, args
+
+
+def _destruncar(comm, args):
+    """`comm` viene del kernel y está capado a 15 caracteres: `willay-crosscheck` llega como
+    `willay-crossche`. Ese nombre truncado no es cosmético — es el que el plan mete en
+    `--in /etc/init.d/<nombre>` (ruta que no existe) y el que va de `label` en la tarjeta de arje.
+    La línea de comando sí trae el nombre entero, así que se usa ESA cuando confirma al truncado."""
+    if len(comm) < 15:
+        return comm
+    p = args.split()
+    if p:
+        base = os.path.basename(p[0])
+        if base.startswith(comm):
+            return base
+    return comm
+
+
 def procesos_vivos(t):
     """Lo que REALMENTE corre, colgado de PID 1. La verdad, no la declaración."""
-    out, _ = t.run("ps -eo pid,ppid,comm 2>/dev/null | awk '$2==1 && $1!=1 {print $1\" \"$3}'")
+    out, _ = t.run("ps -eo pid=,ppid=,comm=,args= 2>/dev/null | awk '$2==1 && $1!=1'")
     vivos = {}
     for l in out.splitlines():
-        parts = l.split()
-        if len(parts) == 2 and parts[0].isdigit():
-            vivos.setdefault(parts[1], []).append(int(parts[0]))
+        parts = l.split(None, 3)
+        if len(parts) < 3 or not parts[0].isdigit():
+            continue
+        pid, comm, args = int(parts[0]), parts[2], (parts[3] if len(parts) > 3 else "")
+        nombre, args = _desenvolver(comm, args)
+        nombre = _destruncar(nombre, args)
+        exe, linea, usuario = _mandado_por_supervisor(args) if comm[:15] in _COMM_SUP else ("", "", "")
+        if exe:
+            SUPERVISADOS[pid] = {"exec": exe, "cmdline": linea, "usuario": usuario}
+        vivos.setdefault(nombre, []).append(pid)
     # los kernel threads no son servicios
     return {k: v for k, v in vivos.items() if not k.startswith(("kworker", "kthread", "ksoftirq",
                                                                 "migration", "rcu_", "irq/", "idle_"))}
@@ -320,6 +415,11 @@ def fuera_de_git(t):
 NO_SERVICIOS = {
     "agetty", "getty", "login", "bash", "sh", "dash", "zsh", "ssh", "sshd-session", "su", "sudo",
     "bwrap", "ps", "awk", "sed", "tail", "sleep", "rsync", "scp", "cat", "python3", "perl",
+    # Restos de tubería reparentados a PID 1 cuando su padre muere. Medido: `head -15` figuraba como
+    # servicio `no-declarado` con ppid==1. No se esconden —salen en `descartados`, porque un huérfano
+    # ES un hallazgo— pero no son servicios y contarlos como tales es el ruido que mata un censo.
+    "head", "tr", "cut", "grep", "wc", "find", "xargs", "tee", "sort", "uniq", "env", "nohup",
+    "timeout", "flock", "tar", "gzip", "zstd", "dd", "md5sum", "sha256sum", "less", "more",
 }
 
 
@@ -432,6 +532,10 @@ def emitir_toml(c):
         L.append(f"cmdline = {toml_escape(s.get('cmdline',''))}")
         L.append(f"cwd = {toml_escape(s.get('cwd',''))}")
         L.append(f"exec = {toml_escape(s.get('exec',''))}")
+        if s.get('usuario'):
+            L.append(f"usuario = {toml_escape(s['usuario'])}")
+        if s.get('exec_motivo'):
+            L.append(f"exec_motivo = {toml_escape(s['exec_motivo'])}")
         L.append(f"paquete = {toml_escape(s.get('paquete',''))}")
         L.append('decision = ""   # muda | muere | (vacío = sin decidir)')
         L.append("")
@@ -521,12 +625,28 @@ def main():
         i = inv.get(e['name'], {})
         e['cmdline'] = i.get('cmdline', '')
         e['cwd'] = i.get('cwd', '')
-    ejes = [ (e['cmdline'] or '').split(' ')[0] for e in svc if (e.get('cmdline') or '').startswith('/') ]
+        e['exe_real'] = i.get('exe', '')
+        if i.get('usuario'):
+            e['usuario'] = i['usuario']
+    # `/proc/<pid>/exe` MANDA sobre `argv[0]`: es el binario de verdad y no lo puede reescribir el
+    # proceso. `argv[0]` queda de respaldo para cuando la sonda no tuvo permiso.
+    def _exe_de(e):
+        return e.get('exe_real') or (
+            (e.get('cmdline') or '').split(' ')[0] if (e.get('cmdline') or '').startswith('/') else '')
+    ejes = [x for x in (_exe_de(e) for e in svc) if x]
     dn = duenos(t, ejes)
+    raiz = os.geteuid() == 0 if a.local else None
     for e in svc:
-        exe = (e.get('cmdline') or '').split(' ')[0]
-        e['exec'] = exe if exe.startswith('/') else ''
+        exe = _exe_de(e)
+        e['exec'] = exe
         e['paquete'] = dn.get(exe, '')
+        if not exe and e.get('pids'):
+            # Decir POR QUÉ no se sabe. «Averigualo a mano» y «volvé a correr el censo con sudo» son
+            # dos trabajos muy distintos, y confundirlos manda a alguien a investigar un permiso.
+            e['exec_motivo'] = ("el censo no corrió como root: `/proc/<pid>/exe` de un proceso de "
+                                "otro usuario no se puede leer. Repetilo con sudo"
+                                if raiz is False else
+                                "el proceso reescribió su argv[0] y no se pudo leer /proc/<pid>/exe")
     pts = puertos(t)
     nombres = dominios(t)
     sondear = (a.probe or a.probe_dns or bool(a.host)) and not a.no_probe
diff --git a/scripts/mudanza/planear.py b/scripts/mudanza/planear.py
index 093ea722..856e7f65 100755
--- a/scripts/mudanza/planear.py
+++ b/scripts/mudanza/planear.py
@@ -301,7 +301,8 @@ def origen_binario(e, repo=None):
                 f"NADIE lo provee: binario puesto a mano en {exe}. Hay que LLEVARLO (con lo que "
                 f"necesite alrededor) o escribirle una receta — es de los que se pierden al apagar "
                 f"el origen")
-    return ("desconocido", "no se pudo leer su binario: hace falta averiguarlo a mano")
+    motivo = e.get("exec_motivo") if isinstance(e, dict) else ""
+    return ("desconocido", motivo or "no se pudo leer su binario: hace falta averiguarlo a mano")
 
 
 def recomendar(tipo, e, datos_que_mueren=()):
@@ -427,6 +428,12 @@ def revisar(censo, interactivo=True):
             if t == "servicio" and e.get("decision") != "muere" and g != "muere":
                 oc, od = origen_binario(e)
                 print(f"            └─ binario: [{oc}] {od[:96]}")
+                if e.get("usuario") and e["usuario"] != "root":
+                    # No es un detalle: la tarjeta de arje NO tiene campo de usuario (lo dice el
+                    # escritor `formatos/arje.py`). Si esto no se ve al decidir, un servicio que acá
+                    # corre sin privilegios termina de root en el destino y nadie se entera.
+                    print(f"            └─ ⚠ corre como `{e['usuario']}`, NO como root: el destino "
+                          f"no tiene dónde guardar eso — hay que bajar el privilegio en el comando")
                 lista, rec, motivo = alternativas(n, _REPO)
                 if lista:
                     op = "  ".join(