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…
⎇ ff78b3d54fb9786c53bf223dde4d428469948289
scripts/test-atuq-ruteo.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
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())