gioser

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 815Rust 56Shell 37Markdown 291229 Definiciones

⎇ 476168bb077db780e49c83e3ca4f0d3d230bd58a scripts/test-atuq-ruteo.py Python · 305 líneas · 14.4 KB Historia Quién tocó qué crudo

takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4 476168bb0 · Sergio ·

1#!/usr/bin/env python3
2"""¿Una petición hecha DENTRO de un contenedor sale por el proxy de ESE contenedor?
3
4    python3 scripts/test-atuq-ruteo.py                     # sale 0 si el ruteo funciona
5    python3 scripts/test-atuq-ruteo.py --negative-control  # SABOTEA la config y exige que se note
6
7Es la afirmación central del SDD 26 §6.8, y la que la v0.5 dejó sin probar. Esta prueba **no le
8pregunta nada al navegador**: le pone dos oídos en la red y mira a cuál de los dos llama.
9
10    destino   el de verdad; contesta un HTTP 200
11    proxy     el de «Banco»; no habla SOCKS, sólo anota quién lo saludó
12
13Los dos puertos los elige el KERNEL (bind al 0) y se atan en el hilo principal antes de empezar:
14fijos, chocaban con lo que otro frente tuviera levantado en la misma máquina, y el choque se leía
15como un fallo del navegador.
16
17Dos pestañas piden la MISMA url. Si el ruteo funciona:
18
19  · la pestaña SIN contenedor  ⇒ llega un `GET /directo` al DESTINO
20  · la pestaña en «Banco»      ⇒ llega un saludo SOCKS5 (`\\x05…`) al PROXY, y NADA al destino
21
22EL CONTROL NEGATIVO NO ES UN EXTRA. Con `--negative-control` no se le pone proxy a «Banco» y se
23exige el resultado CONTRARIO: que las dos pestañas salgan directas. Una prueba que nunca falló no se
24sabe si sirve — sin este modo, una prueba rota que siempre dice «ok» se ve idéntica a una que
25funciona. Los dos modos tienen que correr: el positivo caza que el ruteo se apague, el negativo caza
26que la prueba se haya vuelto ciega.
27
28**El saludo SOCKS5 en el puerto del proxy es la prueba.** Sólo puede aparecer si Gecko decidió hablar con un
29proxy para esa petición, y el único que se lo pudo indicar es el `proxy.onRequest` de
30`extensions/proxy/` mirando el `cookieStoreId`. La pestaña sin contenedor **no es decorativa**: es
31el control. Sin ella, «todo fue por el proxy» y «el ruteo anda» se verían iguales, igual que dos
32silencios se verían iguales que dos aciertos.
33
34POR QUÉ LAS PESTAÑAS SE ABREN POR SESSIONSTORE, que es la parte no obvia. Abrir una pestaña EN un
35contenedor no se puede desde la línea de comandos (no hay flag), ni desde Marionette sin contexto
36chrome (pide `-remote-allow-system-access`), ni desde la propia extensión sin el permiso `cookies`
37—`ext-tabs-base.js:getUserContextIdForCookieStoreId` lo exige—, y ese permiso no se le da a la
38extensión del proxy sólo para poder probarla: sería pagar con la superficie de ataque del producto
39una comodidad del test. Pero el fichero de sesión SÍ guarda el `userContextId` de cada pestaña y
40Gecko lo restaura, así que se fabrica a mano. El formato es `mozLz40\\0` + tamaño + un bloque LZ4;
41un bloque de SÓLO LITERALES es LZ4 válido y se escribe en veinte líneas sin depender de nada.
42
43REQUISITOS: el rootfs de `scripts/atuq-nested.sh` (que lo hidrata) y `atuq` sellado.
44"""
45import json
46import os
47import shutil
48import socket
49import struct
50import subprocess
51import sys
52import tempfile
53import threading
54import time
55
56ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
57RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
58ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
59HAMMER = os.path.join(ROOT, "target/release/takana")
60STORE = os.path.join(ROOT, "store")
61# ── LOS PUERTOS LOS ELIGE EL SISTEMA, Y NO ES UN DETALLE ──────────────────────────────────────
62# Estaban fijos en 8099/9099 y el 2026-09-07 esta prueba dio «NO concluyente» durante media hora por
63# una razón que no tenía nada que ver con atuq: **otro agente de la misma máquina tenía levantado un
64# `python3 -m http.server 8099`**. La oreja del destino no podía atarse, no llegaba nada, y el
65# informe decía que la pestaña sin contenedor no había salido directa — o sea, un fallo del producto
66# que no existía. En un repo que comparten varios frentes, un puerto fijo es estado compartido sin
67# dueño. Ahora se pide el 0 y el kernel da uno libre.
68
69visto = {"destino": [], "proxy": []}
70
71
72def fatal(msg):
73    print(f"✗ {msg}", file=sys.stderr)
74    raise SystemExit(1)
75
76
77# ── El artefacto se resuelve por `takana hash`, NUNCA por glob ────────────────────────────────
78# Con dos artefactos de la misma receta conviviendo en el store, `ls | head -1` es una ruleta: se
79# puede estar probando el de ayer y leerlo como éxito. La receta dice cuál es el vigente.
80def artefacto(receta, nombre):
81    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
82                       cwd=ROOT, capture_output=True, text=True)
83    if r.returncode != 0:
84        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
85    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
86    d = os.path.join(STORE, f"{h}-{nombre}")
87    if not os.path.isdir(d):
88        fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
89    return d
90
91
92def oreja(etiqueta):
93    """Un socket ATADO a un puerto libre, elegido por el kernel. Se ata acá, en el hilo principal.
94
95    Antes se ataba dentro del hilo, y ahí `fatal()` no puede matar el proceso: `SystemExit` en un
96    hilo secundario **sólo termina ese hilo**. El error se imprimía y la prueba seguía adelante
97    midiendo un puerto que nunca escuchó — que es peor que no medir, porque publica un veredicto."""
98    s = socket.socket()
99    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
100    try:
101        s.bind(("127.0.0.1", 0))
102    except OSError as e:
103        fatal(f"no pude abrir la oreja de {etiqueta} ({e}) — la medición no valdría nada")
104    s.listen(8)
105    return s, s.getsockname()[1]
106
107
108def oido(s, etiqueta, responder, hasta):
109    s.settimeout(1.0)
110    while time.time() < hasta:
111        try:
112            c, _ = s.accept()
113        except socket.timeout:
114            continue
115        except OSError:
116            break
117        try:
118            c.settimeout(3)
119            visto[etiqueta].append(c.recv(512))
120            if responder:
121                c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
122        except OSError:
123            pass
124        finally:
125            c.close()
126    s.close()
127
128
129# ── mozlz4 a mano ─────────────────────────────────────────────────────────────────────────────
130def lz4_literales(data: bytes) -> bytes:
131    out = bytearray()
132    n = len(data)
133    if n < 15:
134        out.append(n << 4)
135    else:
136        out.append(0xF0)
137        r = n - 15
138        while r >= 255:
139            out.append(255)
140            r -= 255
141        out.append(r)
142    out += data
143    return bytes(out)
144
145
146def mozlz4(obj) -> bytes:
147    raw = json.dumps(obj, separators=(",", ":")).encode()
148    return b"mozLz40\0" + struct.pack("<I", len(raw)) + lz4_literales(raw)
149
150
151def pestana(url, titulo, ucid):
152    # `triggeringPrincipal_base64: vQ==` es el principal de SISTEMA serializado. SIN un principal
153    # Gecko restaura la pestaña pero NO navega, y la prueba mediría un silencio en vez de un fallo.
154    e = {"url": url, "title": titulo, "triggeringPrincipal_base64": "vQ=="}
155    t = {"entries": [e], "index": 1, "hidden": False, "lastAccessed": 1, "searchMode": None}
156    if ucid:
157        t["userContextId"] = ucid
158    return t
159
160
161def correr(tmp, atuq, segundos):
162    guion = f"""
163    mkdir -p /root /salida/perfil
164    [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
165    timeout {segundos} /usr/bin/atuq --profile /salida/perfil --no-remote --headless \
166        >> /salida/consola.log 2>&1
167    exit 0
168    """
169    cmd = [
170        "bwrap",
171        "--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq,
172        "--overlay-src", os.path.join(tmp, "politica"), "--tmp-overlay", "/",
173        "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
174        "--tmpfs", "/tmp", "--tmpfs", "/run",
175        "--bind", os.path.join(tmp, "salida"), "/salida",
176        "--uid", "0", "--gid", "0",
177        "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
178        "--setenv", "LD_LIBRARY_PATH", "/usr/lib:/lib",
179        "--setenv", "XDG_RUNTIME_DIR", "/run", "--setenv", "HOME", "/root",
180        "--setenv", "XDG_DATA_DIRS", "/usr/share",
181        "--setenv", "MOZ_HEADLESS", "1",
182        # Dentro de bwrap ya no se puede crear otro user namespace (`uid_map: EPERM`) ⇒ el sandbox
183        # de contenido de Gecko no arranca y CADA proceso de pestaña muere con SIGSEGV en bucle.
184        # Acá se mide el ruteo, no el sandbox del motor.
185        "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
186        "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
187        "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
188        "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
189        "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
190        "sh", "-c", guion,
191    ]
192    subprocess.run(cmd, check=False)
193
194
195def main():
196    negativo = "--negative-control" in sys.argv[1:]
197    if negativo:
198        print("== MODO CONTROL NEGATIVO: «Banco» se deja SIN proxy y se exige que NO se rutee")
199    if not os.path.isdir(RFS):
200        fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata")
201    atuq = artefacto("atuq", "atuq")
202    print(f"artefacto: {os.path.basename(atuq)}")
203
204    # Las dos orejas se atan ANTES de tocar nada: si no hay puertos, no hay prueba.
205    sock_destino, PUERTO_DESTINO = oreja("destino")
206    sock_proxy, PUERTO_PROXY = oreja("proxy")
207    URL = "http://127.0.0.1:%d" % PUERTO_DESTINO
208    print(f"   orejas: destino {PUERTO_DESTINO} · proxy {PUERTO_PROXY}")
209
210    tmp = tempfile.mkdtemp(prefix="atuq-ruteo-")
211    perfil = os.path.join(tmp, "salida", "perfil")
212    os.makedirs(os.path.join(tmp, "salida"))
213    pol_dir = os.path.join(tmp, "politica", "usr", "lib", "atuq", "distribution")
214    os.makedirs(pol_dir)
215    try:
216        # La política de la prueba sale de la del ARTEFACTO, con una sola cosa cambiada. Escribirla
217        # de cero probaría una política que no es la que se distribuye.
218        pol = json.load(open(os.path.join(atuq, "usr/lib/atuq/distribution/policies.json")))
219        if not negativo:
220            pol["policies"]["3rdparty"]["Extensions"]["proxy@atuq.tawasuyu"]["proxies"]["Banco"] = {
221                "type": "socks", "host": "127.0.0.1", "port": PUERTO_PROXY, "proxyDNS": True}
222        json.dump(pol, open(os.path.join(pol_dir, "policies.json"), "w"), indent=2)
223
224        hasta = time.time() + 240
225        for sk, et, resp in ((sock_destino, "destino", True), (sock_proxy, "proxy", False)):
226            threading.Thread(target=oido, args=(sk, et, resp, hasta), daemon=True).start()
227        time.sleep(0.5)
228
229        print("== pasada 1: crear el perfil (la política crea los contenedores)")
230        correr(tmp, atuq, 40)
231        cont = os.path.join(perfil, "containers.json")
232        if not os.path.exists(cont):
233            fatal("no se creó containers.json — ¿la política no aplicó?")
234        ids = {i["name"]: i["userContextId"]
235               for i in json.load(open(cont))["identities"] if "name" in i}
236        if "Banco" not in ids:
237            fatal(f"no está el contenedor «Banco» (hay {sorted(ids)})")
238        print(f"   «Banco» es userContextId={ids['Banco']}")
239
240        print("== fabricar la sesión: dos pestañas, misma url, distinto contenedor")
241        sesion = {
242            "version": ["sessionrestore", 1],
243            "windows": [{"tabs": [pestana(f"{URL}/directo", "directo", None),
244                                  pestana(f"{URL}/banco", "banco", ids["Banco"])],
245                         "selected": 1, "_closedTabs": [], "busy": False}],
246            "selectedWindow": 1,
247            "session": {"lastUpdate": 1, "startTime": 1, "recentCrashes": 0},
248            "global": {},
249        }
250        bkp = os.path.join(perfil, "sessionstore-backups")
251        os.makedirs(bkp, exist_ok=True)
252        open(os.path.join(bkp, "recovery.jsonlz4"), "wb").write(mozlz4(sesion))
253        with open(os.path.join(perfil, "user.js"), "a") as fh:
254            fh.write('user_pref("browser.startup.page", 3);\n')
255            # Sin esto las pestañas restauradas quedan PEREZOSAS: no piden nada hasta que alguien
256            # las mira, y la prueba mediría dos silencios en vez de dos peticiones.
257            fh.write('user_pref("browser.sessionstore.restore_on_demand", false);\n')
258            # Gecko saltea el proxy para localhost salvo que se le diga lo contrario. Sin esto la
259            # petición de «Banco» iría directa y el fallo parecería de la extensión.
260            fh.write('user_pref("network.proxy.allow_hijacking_localhost", true);\n')
261
262        print("== pasada 2: restaurar la sesión y mirar quién llama a cada puerto")
263        visto["destino"].clear()
264        visto["proxy"].clear()
265        correr(tmp, atuq, 45)
266
267        print(f"   al DESTINO ({PUERTO_DESTINO}) llegaron {len(visto['destino'])}: "
268              f"{[d[:24] for d in visto['destino']]}")
269        print(f"   al PROXY   ({PUERTO_PROXY}) llegaron {len(visto['proxy'])}: "
270              f"{[d[:24] for d in visto['proxy']]}")
271
272        directo = any(b"/directo" in d for d in visto["destino"])
273        banco_directo = any(b"/banco" in d for d in visto["destino"])
274        socks = any(d[:1] == b"\x05" for d in visto["proxy"])
275        if negativo:
276            casos = [
277                ("las DOS pestañas fueron directas al destino", directo and banco_directo),
278                ("y al proxy no llamó nadie", not socks),
279            ]
280        else:
281            casos = [
282                ("la pestaña SIN contenedor fue DIRECTA al destino", directo),
283                ("la de «Banco» NO apareció en el destino", not banco_directo),
284                ("al proxy llegó un saludo SOCKS5", socks),
285            ]
286        for nombre, ok in casos:
287            print(f"   [{'ok' if ok else 'NO'}] {nombre}")
288        bien = all(ok for _, ok in casos)
289        if negativo:
290            veredicto = ("sin proxy configurado NO se rutea, y la prueba lo distingue" if bien
291                         else "NO concluyente: la prueba no distingue el caso sin proxy")
292        else:
293            veredicto = ("el contenedor SÍ sale por su proxy" if bien
294                         else "NO concluyente — mirá consola.log")
295        print("RESULTADO:", veredicto)
296        if not bien:
297            shutil.copy(os.path.join(tmp, "salida", "consola.log"),
298                        os.path.join(ROOT, "work", "atuq-ruteo.log"))
299            print("   log en work/atuq-ruteo.log")
300        return 0 if bien else 1
301    finally:
302        shutil.rmtree(tmp, ignore_errors=True)
303
304
305sys.exit(main())