> 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: 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
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(