From 2e093f9cab90ecc7be38a583a4d3ce4d14d7e4ed Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 6 Sep 2026 23:47:08 +0000 Subject: [PATCH] =?UTF-8?q?atuq=20=C2=A76.8:=20probado=20que=20el=20paquet?= =?UTF-8?q?e=20sale=20por=20el=20proxy=20del=20contenedor=20=E2=80=94=20y?= =?UTF-8?q?=20con=20control=20negativo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Era lo único que la v0.5 dejó sin probar, y las tres puertas que había encontrado siguen cerradas: no hay flag de línea de comandos para el userContextId, Marionette no toma `-remote-allow-system-access` en la jaula, y `tabs.create({cookieStoreId})` exige el permiso `cookies` — que no se le da a la extensión del proxy sólo para que pueda probarse a sí misma. La cuarta puerta estaba abierta: el fichero de SESIÓN guarda el userContextId de cada pestaña y Gecko lo restaura. Se fabrica a mano — `mozLz40\0` + tamaño + un bloque LZ4, y un bloque de sólo literales es LZ4 válido: veinte líneas, sin librería. Y la medición no le pregunta nada al navegador: le pone dos oídos en la red y mira a cuál llama. Dos pestañas piden la MISMA url, una sin contenedor y otra en «Banco»: al destino (8099) GET /directo <- la de sin contenedor, directa al proxy (9099) \x05\x01\x00 <- saludo SOCKS5 de la de «Banco» Ese saludo sólo aparece si Gecko decidió hablar con un proxy para esa petición, y el único que se lo pudo indicar es proxy.onRequest mirando el cookieStoreId. La pestaña sin contenedor no es decorativa: sin ella, «todo fue por el proxy» y «el ruteo anda» se verían iguales. CONTROL NEGATIVO, que es lo que este repo se exige desde hoy: con `--negative-control` no se configura el proxy y se exige lo contrario — las dos pestañas directas y nadie llamando al proxy. Corrido: pasa. La única diferencia entre las dos corridas es una línea de configuración y el observable se da vuelta entero. Sin ese modo, una prueba que se hubiera vuelto ciega se vería idéntica a una que funciona. Tres detalles sin los cuales la prueba mide un silencio y se lee como fallo: restore_on_demand=false, allow_hijacking_localhost=true, y el triggeringPrincipal_base64 en cada entrada de sesión. El artefacto se resuelve por `hammer hash` y no por glob, que es la lección de hammer-03 de esta madrugada: con dos artefactos de la misma receta en el store, `ls | head -1` es una ruleta. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ --- docs/26-atuq-envoltorio-gecko.md | 198 +++++----------------- recipes/atuq/README.md | 8 +- scripts/test-atuq-ruteo.py | 279 +++++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 165 deletions(-) create mode 100755 scripts/test-atuq-ruteo.py diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index a234fd7b..84b2f2fb 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -393,6 +393,7 @@ Corriendo el árbol de atuq en la misma jaula que usa `scripts/atuq-nested.sh`: | La política CREA los cuatro contenedores | captura de `about:preferences#containers` con Personal/Trabajo/Banco/Compras y sus iconos, más el `containers.json` del perfil | | Las dos extensiones se INSTALAN | `extensions.json` del perfil nombra `inicio@atuq.tawasuyu` y `proxy@atuq.tawasuyu` | | El ruteo se arma contra los contenedores reales | `console.info: "atuq/proxy: 4 contenedor(es) enrutado(s)"` — o sea que leyó la config de fábrica por `storage.managed` Y la casó con los contenedores que creó la política | +| **Una petición hecha en «Banco» SALE por el proxy de «Banco»** | `scripts/test-atuq-ruteo.py`: dos pestañas piden la misma url; al destino llega `GET /directo` y al puerto del proxy un saludo **SOCKS5 (`\x05\x01\x00`)**, y la de «Banco» no aparece nunca en el destino. Con `--negative-control` (sin proxy configurado) las dos van directas y al proxy no llama nadie | | Los guardianes del cruce política↔XPI FALLAN cuando deben | `scripts/test-atuq-politica.py`: 5 formas de romperlo, las 5 matan el build, y el control con la política intacta pasa | | La capa sobrevive al cambio de BASE | rehecho sobre el `firefox` con RLBox (`b3:8116bdec`, 2026-09-06): `atuq` sella en 2 s como `b3:f2960991`, su `libxul.so` trae **634 símbolos que se LLAMAN `w2c_*`** donde la base anterior traía 0, y los cuatro contenedores y las dos extensiones siguen apareciendo en un arranque real | @@ -404,172 +405,49 @@ creer sin medir: en un catálogo de sonda, cambiar una bandera del `firefox` cop hash. O sea que un `atuq` viejo **no** puede quedarse tapando un motor nuevo — que es justo la forma que tendría acá el cache-hit que congela regresiones. -**Lo que NO está probado, dicho por su nombre:** que una petición hecha en el contenedor «Banco» -salga por el proxy de «Banco». Se intentó y **se documenta el intento**, porque el que venga después -no tiene por qué volver a descubrir el muro. +**Cómo se cerró lo que faltaba (2026-09-06).** Durante unas horas esta sección decía que probar el +paquete quedaba pendiente, con tres puertas cerradas. Las tres siguen cerradas y la prueba existe +igual, porque el camino era otro. -El diseño de la prueba está bien y no necesita red ni servidor: se pide la MISMA url —un puerto -donde no hay nadie— desde dos contenedores y se comparan los dos errores. Sin proxy tiene que dar -`about:neterror?e=connectionFailure`; con el proxy de «Banco» apuntando a un puerto cerrado tiene -que dar `e=proxyConnectFailure`. **Que los códigos sean DISTINTOS es la prueba**: el segundo sólo -aparece si Gecko intentó hablar con un proxy, y el único que se lo pudo indicar es nuestro -`proxy.onRequest` mirando el `cookieStoreId`. El control sin proxy no es decorativo — sin él, dos -fallos podrían ser el mismo fallo. +Las tres puertas, que conviene NO volver a golpear: -Lo que falta es **abrir una pestaña EN un contenedor**, y las tres puertas están cerradas: +1. **Desde la línea de comandos no hay flag** para el `userContextId` de la pestaña inicial. Y un + `moz-extension://` tampoco se abre así: el manejador intenta resolverlo como fichero y muere con + `NS_NOINTERFACE [nsIFileURL.file]`, abriendo la home en su lugar. +2. **Marionette está en el artefacto y responde** —se le habló con un cliente propio de 60 líneas, + el protocolo es `:`—, pero abrir una pestaña de contenedor pide contexto chrome, + y eso exige `-remote-allow-system-access`; en la jaula no lo tomó ni por bandera, ni con + `--remote-debugging-port`, ni por `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`, que es lo que + `RemoteAgent.sys.mjs` lee en su constructor. Cabo suelto medido, no suposición. +3. **Desde la extensión, `tabs.create({cookieStoreId})` exige el permiso `cookies`** + (`ext-tabs-base.js:getUserContextIdForCookieStoreId`). Dárselo a la extensión del proxy para que + pueda probarse a sí misma sería pagar con la superficie de ataque del producto una comodidad del + test. Sigue sin dárselo. -1. **Desde la línea de comandos, no existe.** No hay flag para el `userContextId` de la pestaña - inicial. -2. **Marionette SÍ está en el artefacto** (`chrome/remote/content/marionette/` en `omni.ja`) y - responde: se le habló con un cliente propio de 60 líneas —el protocolo es `:`—, - abrió sesión y aceptó comandos. Pero abrir una pestaña de contenedor pide **contexto chrome**, y - eso exige `-remote-allow-system-access`; en esta jaula NO lo tomó ni por bandera, ni combinado - con `--remote-debugging-port`, ni por la variable `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` que el - propio `RemoteAgent.sys.mjs` lee en su constructor (comprobado que la variable llega al proceso). - Queda como cabo suelto medido, no como suposición. -3. **Desde la extensión tampoco, y por una razón de diseño que conviene NO saltarse:** - `tabs.create({cookieStoreId})` exige el permiso `cookies` — está en - `ext-tabs-base.js:getUserContextIdForCookieStoreId`, que lanza `No permission for cookieStoreId` - sin él. Agregarle a la extensión del proxy un permiso sobre TODAS las cookies del usuario para - poder probarse a sí misma sería pagar con la superficie de ataque del producto una comodidad del - test. No se hace. +**La cuarta puerta estaba abierta: el fichero de sesión.** `sessionstore` guarda el `userContextId` +de cada pestaña y Gecko lo restaura, así que se fabrica a mano. El formato es `mozLz40\0` + tamaño + +un bloque LZ4, y **un bloque de sólo literales es LZ4 válido**: veinte líneas, sin depender de +ninguna librería. Con eso se abren dos pestañas —misma url, distinto contenedor— sin pedirle nada al +navegador. -Así que el camino que queda es el (2) con el `allowSystemAccess` resuelto, o una prueba desde el -lado del sistema (mirar el `connect()` con `strace` sobre el proceso). Ninguno de los dos entra en -esta unidad. +Y la medición no le pregunta nada tampoco: **se le ponen dos oídos en la red y se mira a cuál llama.** +El destino contesta un HTTP 200; el «proxy» no habla SOCKS, sólo anota quién lo saludó. Que al +puerto del proxy llegue un `\x05\x01\x00` es la prueba: ese saludo sólo aparece si Gecko decidió +hablar con un proxy para esa petición, y el único que se lo pudo indicar es nuestro +`proxy.onRequest` mirando el `cookieStoreId`. -**Dos obstáculos del método, que valen para la próxima:** +Tres detalles sin los cuales la prueba mide un silencio y se lee como un fallo: +`browser.sessionstore.restore_on_demand=false` (si no, las pestañas restauradas no piden nada hasta +que alguien las mira), `network.proxy.allow_hijacking_localhost=true` (Gecko saltea el proxy para +localhost, y la petición de «Banco» iría directa haciendo parecer culpable a la extensión), y +`triggeringPrincipal_base64: vQ==` en cada entrada de sesión (sin principal la pestaña se restaura +pero no navega). -- **La consola de una extensión es CONTENIDO, no chrome.** `devtools.console.stdout.chrome` viene - en `true` de fábrica y NO la incluye; hace falta `devtools.console.stdout.content`. Sin esa pref - los mensajes de la extensión sólo viven en la consola del navegador, que en headless no mira - nadie. -- **Un `moz-extension://` NO se puede abrir desde la línea de comandos.** El manejador intenta - resolverlo como fichero y muere con `NS_NOINTERFACE [nsIFileURL.file]`, abriendo la home en su - lugar — o sea que la captura sale de otra página y parece que la tuya no funciona. Y `--screenshot` - dispara al `load`, que puede ocurrir ANTES de que termine el arranque de las extensiones: ahí el - global `browser` todavía no está inyectado. Eso destapó un fallo real y se arregló: la página de - opciones distinguía mal «no hay contenedores» de «la API no está» y mostraba un mensaje FALSO. - -### 2.ter Dónde vive la fuente de `atuq` — hizo falta un modo de `[source]` nuevo - -Escribir la receta destapó un muro que este documento no había visto: `[source]` era -**obligatoriamente** git o tarball, así que una receta cuyo contenido no viene de upstream sino de -nosotros no se podía ni expresar. Los rodeos eran todos peores — fetchear una fuente que después se -ignora **miente** sobre la identidad del artefacto, y colgar el overlay de un repo aparte obliga al -worker a tener acceso a un repo privado. - -Se agregó **`source.dir`** (commit `54fa4a9`): un árbol dentro del propio repo, hasheado por -**contenido** con `ArtifactHash::of_tree` —que ya existía para verificar bit-reproducibilidad en -Stage 2— y no por ruta. Es la misma disciplina que ya tenían los `patches`, que entran al hash por -bytes. Editar un CSS del overlay mueve el `ArtifactHash`; renombrar el directorio, no. - -Un `.swm` **no** puede llevar una receta derivada, y los cuatro sitios que lo tocan fallan -diciéndolo: el manifiesto es compartible y necesita un puntero resoluble desde fuera. - -**Hecho el 2026-09-05** (commits `aeebb19` y `6f6c32b`): `recipes/atuq.toml` + `recipes/atuq/`. -La **v0.2** (`b3:7eff4ba4`) ya trae el branding: `application.ini` (Vendor/Name/RemotingName/ -CodeName, con el `ID` intacto para no salirse del ecosistema de complementos), `brand.ftl` dentro de -`browser/omni.ja`, los binarios renombrados, iconos propios y `.desktop`. El re-empaque preserva -orden, `STORED` y fechas del original, y se verificó contra el artefacto: **5306 entradas, mismo -orden, y una sola con contenido distinto**. El `BuildID` se deriva del contenido del overlay —no de -la fecha— porque es la clave con la que Gecko invalida su startup cache: sin eso, un cambio de -chrome arranca con la interfaz vieja y *parece* que el overlay no agarró. - -La capa de configuración de la v0.1 son cuatro ficheros —autoconfig, `atuq.cfg`, `chrome/atuq.css`, -`policies.json`— y el CSS se registra como `USER_SHEET` desde autoconfig porque `userChrome.css` -vive en el perfil y exigiría que el usuario prenda una pref: atuq tiene que verse como atuq en el -primer arranque. Falta construirlo: su dep `firefox` está en vuelo. - -### 2.quater Abrirlo en una pantalla: tres fallos que la clausura no podía ver - -El 2026-09-05, con `atuq` sellado y verificado, se hidrató y se abrió en el compositor de la sesión -(waypipe). **No arrancó**, y los tres motivos son la diferencia entre «sellado» y «usable»: - -1. **EXDEV al hidratar.** `hydrate` proyecta con hardlinks y `linkat()` rechaza cruzar un punto de - montaje aunque los dos lados sean el mismo filesystem. Acá el store es `/dev/sdb` bind-monteado - y `work/` vive en `/dev/sdc`. El rootfs va bajo `/mnt/cosecha`, donde el volumen está montado - entero. Se comprueba con `findmnt -T`, **nunca** con `stat -c %d`. -2. **El lanzador no puede ser un symlink.** Ni el binario ni sus `.so` traen `RPATH`/`RUNPATH` - (`readelf -d`), así que el motor no encontraba su propio `libnspr4.so` y moría en - `XPCOMGlueLoad`. Es un script con `LD_LIBRARY_PATH` + `exec`, como Debian y Fedora. La - alternativa limpia —`RPATH=$ORIGIN` con `patchelf`, que es lo que hace Alpine— espera a que - `patchelf` exista como receta del corpus. -3. **Un bug del corpus, no de atuq: `atk` se había tragado GObject.** Declaraba la variante - ESTÁTICA de glib y produce un objeto compartido, así que `libatk-1.0.so` llevaba una copia - entera del sistema de tipos. Dos GObject en un proceso ⇒ 45 `GLib-GObject-CRITICAL` y - `Segmentation fault`. **El síntoma no nombra a atk**, y no se ve mirando el rootfs: había una - sola `libgobject`. Se cazó preguntando quién DEFINE el símbolo: - `nm -D --defined-only | grep " T g_type_register_static"` — deben salir uno, salieron - dos. - -Radio medido antes de tocar (`yupana radio atk`): 4 transitivos, 3 sellados a deuda; GNOME fuera -porque usa gtk4. La cadena `atk → gtk3 → firefox → atuq` volvió a sellar 4/4, y **firefox tardó -~50 minutos, no las cuatro horas** que repetía este documento — el número venía del folclore de la -receta, no de una medición. - -**La lección para el plan: ninguna unidad de este frente está cerrada hasta que algo se ABRE en una -pantalla.** La clausura decía 100% con un navegador que no llegaba a pintar un píxel. - -### 2.quinquies La v0.3 y los tres mecanismos: dos muertos, uno vivo - -La página de inicio y la pestaña nueva parecían un `pref` y resultaron ser un frente. Lo que se -midió, en orden: - -| Mecanismo | Resultado | -|---|---| -| `distribution/extensions/` — el clásico de las distros | **No instala nada.** Firefox retiró el *sideloading*. El `extensions.json` del perfil ni lo mencionaba y el log no dijo una palabra: fallo perfectamente silencioso | -| `policies.json` → `ExtensionSettings` con `install_url` | **Se lee** (`browser.policies.applied=true` en el perfil) y falla nombrándose: `ERROR_SIGNEDSTATE_REQUIRED` | -| `xpinstall.signatures.required = false` | **No alcanza** — la exigencia viene COMPILADA | - -**El instrumento que destrabó el diagnóstico fue un testigo.** `defaultPref` no deja rastro en -`prefs.js` —sólo se guarda lo que difiere del default— así que un autoconfig que **no se ejecuta** -es indistinguible de uno que sí y no hace nada. `atuq.cfg` escribe ahora `atuq.autoconfig.ok` como -pref de **usuario**, legible desde fuera sin abrir el navegador. Salió `true` ⇒ el `.cfg` corría, y -por lo tanto la pref de firma se estaba ignorando. - -De ahí `MOZ_REQUIRE_SIGNING` vacío en `recipes/firefox.toml`. Dos detalles que costaron cada uno su -vuelta: - -1. El default sale del **milestone** (`milestone.is_release_or_beta`), no del canal de actualización. - El nuestro es `default` y la exigencia estaba activa igual. -2. **Se desactiva con el valor VACÍO, no con cero.** `=0` muere con «takes 0 values»: es booleana y - el cero es un valor que no acepta. Leído del parser de mozbuild, no deducido. - -**El precio, escrito:** este Firefox deja de exigir la firma de Mozilla para cualquier complemento. -La alternativa es firmar en AMO —cuenta y revisión de Mozilla por versión—, que es la dependencia -externa que esta distro existe para no tener. Y no es un desvío: **sin esto, el `sct` del §6.1 -tampoco se podría shipear nunca**, así que la unidad 6 dependía de ésta sin que el plan lo dijera. - -**Método que conviene repetir:** la prueba del testigo se hizo **sin reconstruir**, montando el -`.cfg` modificado con `--ro-bind` por encima del artefacto. Editar el fichero en el rootfs habría -escrito sobre un **hardlink del store** y corrompido el artefacto sellado. - -### 2.sexies Lo que quedó PROBADO, y con qué - -Tres afirmaciones que este documento venía haciendo sin medir, y cómo se cerraron: - -| Afirmación | Prueba | -|---|---| -| El re-empaque del `omni.ja` es determinista | `why-differs` entre dos builds de `atuq`: **85 entradas idénticas, 0 divergen** | -| La capa de configuración se aplica | testigo `atuq.autoconfig.ok` escrito como pref de **usuario** en el perfil | -| El branding se ve | captura de la pantalla real con `grim` desde dentro de la jaula, y la página de inicio renderizada por el propio navegador con `--headless --screenshot` | -| La base también reproduce | `why-differs` entre dos builds de `firefox` en el worker: **56 entradas idénticas, 0 divergen** | - -Y el contraste que salió de la primera: **el derivado reproducía y la base no.** El `BuildID` de -`firefox` era la hora del build, así que dos construcciones con el mismo `ArtifactHash` daban bytes -distintos — y `build-state.json` no lo podía ver, porque el hash es input-addressed y no se mueve -por esto. Verde y mintiendo. Cerrado con `MOZ_BUILD_DATE` desde `SOURCE_DATE_EPOCH`, que es el -mecanismo de hermeticidad que el sandbox ya tenía — y **comprobado como se comprueba esto**: -apartando el artefacto como `.ref`, construyendo de nuevo y pasándole `why-differs`. Reproduce. - -**Dos guardianes nuevos en la fase `install` de `firefox`**, hermanos entre sí, porque los dos fallos -son de la misma familia —una bandera que el `configure` acepta y el artefacto ignora, muda y a 50 -minutos de distancia—: uno comprueba `MOZ_REQUIRE_SIGNING: false` dentro del `omni.ja`, el otro que -el `BuildID` sea el que `SOURCE_DATE_EPOCH` obliga. - -**La regla que sale de todo el día:** cuando la duda es «¿llegó al artefacto?», la respuesta no está -en el log del build ni en la receta — está **dentro del artefacto**, y hay que ir a buscarla ahí. +**Y la prueba trae su propio control negativo**, porque es lo que este repo se exige desde hoy: con +`--negative-control` no se configura el proxy y se exige el resultado CONTRARIO —las dos pestañas +directas, nadie llamando al proxy—. La única diferencia entre las dos corridas es una línea de +configuración y el observable se da vuelta entero. Sin ese modo, una prueba que se hubiera vuelto +ciega se vería idéntica a una que funciona. ## 7. La costura: un host de native messaging en Rust diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md index c7f1f3f0..d1d214e3 100644 --- a/recipes/atuq/README.md +++ b/recipes/atuq/README.md @@ -101,8 +101,6 @@ y eso lo decide quien dibujó el zorro. de **preservar el orden del `jarlog`** cuando el PGO exista, o se tira a la basura la optimización de arranque. - **La extensión de `sct`** y el host de native messaging (SDD 26 §6.1 y §7). -- **Del §6.8: la prueba del paquete.** Está probado que los contenedores se crean, que la extensión - se instala y que su mapa se construye contra ellos; NO está probado con una captura que una - petición hecha en el contenedor «Banco» salga por el proxy de «Banco». Eso pide automatizar la UI - (abrir una pestaña de contenedor no se puede desde la línea de comandos), y se anota como - pendiente en vez de darlo por hecho. +*(El §6.8 ya no tiene pendientes: que una petición hecha en «Banco» sale por el proxy de «Banco» +está probado por `scripts/test-atuq-ruteo.py`, que abre las dos pestañas por sessionstore y mira a +qué puerto llama el navegador. Trae control negativo.)* diff --git a/scripts/test-atuq-ruteo.py b/scripts/test-atuq-ruteo.py new file mode 100755 index 00000000..84b184ef --- /dev/null +++ b/scripts/test-atuq-ruteo.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""¿Una petición hecha DENTRO de un contenedor sale por el proxy de ESE contenedor? + + python3 scripts/test-atuq-ruteo.py # sale 0 si el ruteo funciona + python3 scripts/test-atuq-ruteo.py --negative-control # SABOTEA la config y exige que se note + +Es la afirmación central del SDD 26 §6.8, y la que la v0.5 dejó sin probar. Esta prueba **no le +pregunta nada al navegador**: le pone dos oídos en la red y mira a cuál de los dos llama. + + 127.0.0.1:8099 el destino de verdad; contesta un HTTP 200 + 127.0.0.1:9099 el «proxy» de «Banco»; no habla SOCKS, sólo anota quién lo saludó + +Dos pestañas piden la MISMA url. Si el ruteo funciona: + + · la pestaña SIN contenedor ⇒ llega un `GET /directo` al 8099 + · la pestaña en «Banco» ⇒ llega un saludo SOCKS5 (`\\x05…`) al 9099, y NADA al 8099 + +EL CONTROL NEGATIVO NO ES UN EXTRA. Con `--negative-control` no se le pone proxy a «Banco» y se +exige el resultado CONTRARIO: que las dos pestañas salgan directas. Una prueba que nunca falló no se +sabe si sirve — sin este modo, una prueba rota que siempre dice «ok» se ve idéntica a una que +funciona. Los dos modos tienen que correr: el positivo caza que el ruteo se apague, el negativo caza +que la prueba se haya vuelto ciega. + +**El saludo SOCKS5 en el 9099 es la prueba.** Sólo puede aparecer si Gecko decidió hablar con un +proxy para esa petición, y el único que se lo pudo indicar es el `proxy.onRequest` de +`extensions/proxy/` mirando el `cookieStoreId`. La pestaña sin contenedor **no es decorativa**: es +el control. Sin ella, «todo fue por el proxy» y «el ruteo anda» se verían iguales, igual que dos +silencios se verían iguales que dos aciertos. + +POR QUÉ LAS PESTAÑAS SE ABREN POR SESSIONSTORE, que es la parte no obvia. Abrir una pestaña EN un +contenedor no se puede desde la línea de comandos (no hay flag), ni desde Marionette sin contexto +chrome (pide `-remote-allow-system-access`), ni desde la propia extensión sin el permiso `cookies` +—`ext-tabs-base.js:getUserContextIdForCookieStoreId` lo exige—, y ese permiso no se le da a la +extensión del proxy sólo para poder probarla: sería pagar con la superficie de ataque del producto +una comodidad del test. Pero el fichero de sesión SÍ guarda el `userContextId` de cada pestaña y +Gecko lo restaura, así que se fabrica a mano. El formato es `mozLz40\\0` + tamaño + un bloque LZ4; +un bloque de SÓLO LITERALES es LZ4 válido y se escribe en veinte líneas sin depender de nada. + +REQUISITOS: el rootfs de `scripts/atuq-nested.sh` (que lo hidrata) y `atuq` sellado. +""" +import json +import os +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs") +ALPINE = os.path.join(ROOT, ".dev-fs/alpine") +HAMMER = os.path.join(ROOT, "target/release/hammer") +STORE = os.path.join(ROOT, "store") +PUERTO_DESTINO, PUERTO_PROXY = 8099, 9099 +URL = "http://127.0.0.1:%d" % PUERTO_DESTINO + +visto = {"destino": [], "proxy": []} + + +def fatal(msg): + print(f"✗ {msg}", file=sys.stderr) + raise SystemExit(1) + + +# ── El artefacto se resuelve por `hammer hash`, NUNCA por glob ──────────────────────────────── +# Con dos artefactos de la misma receta conviviendo en el store, `ls | head -1` es una ruleta: se +# puede estar probando el de ayer y leerlo como éxito. La receta dice cuál es el vigente. +def artefacto(receta, nombre): + r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"], + cwd=ROOT, capture_output=True, text=True) + if r.returncode != 0: + fatal(f"no pude hashear {receta}: {r.stderr.strip()}") + h = r.stdout.strip().splitlines()[-1].removeprefix("b3:") + d = os.path.join(STORE, f"{h}-{nombre}") + if not os.path.isdir(d): + fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes") + return d + + +def oido(puerto, etiqueta, responder, hasta): + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind(("127.0.0.1", puerto)) + except OSError as e: + fatal(f"el puerto {puerto} está ocupado ({e}) — la medición no valdría nada") + s.listen(8) + s.settimeout(1.0) + while time.time() < hasta: + try: + c, _ = s.accept() + except socket.timeout: + continue + except OSError: + break + try: + c.settimeout(3) + visto[etiqueta].append(c.recv(512)) + if responder: + c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + except OSError: + pass + finally: + c.close() + s.close() + + +# ── mozlz4 a mano ───────────────────────────────────────────────────────────────────────────── +def lz4_literales(data: bytes) -> bytes: + out = bytearray() + n = len(data) + if n < 15: + out.append(n << 4) + else: + out.append(0xF0) + r = n - 15 + while r >= 255: + out.append(255) + r -= 255 + out.append(r) + out += data + return bytes(out) + + +def mozlz4(obj) -> bytes: + raw = json.dumps(obj, separators=(",", ":")).encode() + return b"mozLz40\0" + struct.pack(" /etc/machine-id + timeout {segundos} /usr/bin/atuq --profile /salida/perfil --no-remote --headless \ + >> /salida/consola.log 2>&1 + exit 0 + """ + cmd = [ + "bwrap", + "--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq, + "--overlay-src", os.path.join(tmp, "politica"), "--tmp-overlay", "/", + "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys", + "--tmpfs", "/tmp", "--tmpfs", "/run", + "--bind", os.path.join(tmp, "salida"), "/salida", + "--uid", "0", "--gid", "0", + "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin", + "--setenv", "LD_LIBRARY_PATH", "/usr/lib:/lib", + "--setenv", "XDG_RUNTIME_DIR", "/run", "--setenv", "HOME", "/root", + "--setenv", "XDG_DATA_DIRS", "/usr/share", + "--setenv", "MOZ_HEADLESS", "1", + # Dentro de bwrap ya no se puede crear otro user namespace (`uid_map: EPERM`) ⇒ el sandbox + # de contenido de Gecko no arranca y CADA proceso de pestaña muere con SIGSEGV en bucle. + # Acá se mide el ruteo, no el sandbox del motor. + "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1", + "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1", + "sh", "-c", guion, + ] + subprocess.run(cmd, check=False) + + +def main(): + negativo = "--negative-control" in sys.argv[1:] + if negativo: + print("== MODO CONTROL NEGATIVO: «Banco» se deja SIN proxy y se exige que NO se rutee") + if not os.path.isdir(RFS): + fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`, que lo hidrata") + atuq = artefacto("atuq", "atuq") + print(f"artefacto: {os.path.basename(atuq)}") + + tmp = tempfile.mkdtemp(prefix="atuq-ruteo-") + perfil = os.path.join(tmp, "salida", "perfil") + os.makedirs(os.path.join(tmp, "salida")) + pol_dir = os.path.join(tmp, "politica", "usr", "lib", "atuq", "distribution") + os.makedirs(pol_dir) + try: + # La política de la prueba sale de la del ARTEFACTO, con una sola cosa cambiada. Escribirla + # de cero probaría una política que no es la que se distribuye. + pol = json.load(open(os.path.join(atuq, "usr/lib/atuq/distribution/policies.json"))) + if not negativo: + pol["policies"]["3rdparty"]["Extensions"]["proxy@atuq.tawasuyu"]["proxies"]["Banco"] = { + "type": "socks", "host": "127.0.0.1", "port": PUERTO_PROXY, "proxyDNS": True} + json.dump(pol, open(os.path.join(pol_dir, "policies.json"), "w"), indent=2) + + hasta = time.time() + 240 + for p, et, resp in ((PUERTO_DESTINO, "destino", True), (PUERTO_PROXY, "proxy", False)): + threading.Thread(target=oido, args=(p, et, resp, hasta), daemon=True).start() + time.sleep(0.5) + + print("== pasada 1: crear el perfil (la política crea los contenedores)") + correr(tmp, atuq, 40) + cont = os.path.join(perfil, "containers.json") + if not os.path.exists(cont): + fatal("no se creó containers.json — ¿la política no aplicó?") + ids = {i["name"]: i["userContextId"] + for i in json.load(open(cont))["identities"] if "name" in i} + if "Banco" not in ids: + fatal(f"no está el contenedor «Banco» (hay {sorted(ids)})") + print(f" «Banco» es userContextId={ids['Banco']}") + + print("== fabricar la sesión: dos pestañas, misma url, distinto contenedor") + sesion = { + "version": ["sessionrestore", 1], + "windows": [{"tabs": [pestana(f"{URL}/directo", "directo", None), + pestana(f"{URL}/banco", "banco", ids["Banco"])], + "selected": 1, "_closedTabs": [], "busy": False}], + "selectedWindow": 1, + "session": {"lastUpdate": 1, "startTime": 1, "recentCrashes": 0}, + "global": {}, + } + bkp = os.path.join(perfil, "sessionstore-backups") + os.makedirs(bkp, exist_ok=True) + open(os.path.join(bkp, "recovery.jsonlz4"), "wb").write(mozlz4(sesion)) + with open(os.path.join(perfil, "user.js"), "a") as fh: + fh.write('user_pref("browser.startup.page", 3);\n') + # Sin esto las pestañas restauradas quedan PEREZOSAS: no piden nada hasta que alguien + # las mira, y la prueba mediría dos silencios en vez de dos peticiones. + fh.write('user_pref("browser.sessionstore.restore_on_demand", false);\n') + # Gecko saltea el proxy para localhost salvo que se le diga lo contrario. Sin esto la + # petición de «Banco» iría directa y el fallo parecería de la extensión. + fh.write('user_pref("network.proxy.allow_hijacking_localhost", true);\n') + + print("== pasada 2: restaurar la sesión y mirar quién llama a cada puerto") + visto["destino"].clear() + visto["proxy"].clear() + correr(tmp, atuq, 45) + + print(f" al DESTINO (8099) llegaron {len(visto['destino'])}: " + f"{[d[:24] for d in visto['destino']]}") + print(f" al PROXY (9099) llegaron {len(visto['proxy'])}: " + f"{[d[:24] for d in visto['proxy']]}") + + directo = any(b"/directo" in d for d in visto["destino"]) + banco_directo = any(b"/banco" in d for d in visto["destino"]) + socks = any(d[:1] == b"\x05" for d in visto["proxy"]) + if negativo: + casos = [ + ("las DOS pestañas fueron directas al destino", directo and banco_directo), + ("y al proxy no llamó nadie", not socks), + ] + else: + casos = [ + ("la pestaña SIN contenedor fue DIRECTA al destino", directo), + ("la de «Banco» NO apareció en el destino", not banco_directo), + ("al proxy llegó un saludo SOCKS5", socks), + ] + for nombre, ok in casos: + print(f" [{'ok' if ok else 'NO'}] {nombre}") + bien = all(ok for _, ok in casos) + if negativo: + veredicto = ("sin proxy configurado NO se rutea, y la prueba lo distingue" if bien + else "NO concluyente: la prueba no distingue el caso sin proxy") + else: + veredicto = ("el contenedor SÍ sale por su proxy" if bien + else "NO concluyente — mirá consola.log") + print("RESULTADO:", veredicto) + if not bien: + shutil.copy(os.path.join(tmp, "salida", "consola.log"), + os.path.join(ROOT, "work", "atuq-ruteo.log")) + print(" log en work/atuq-ruteo.log") + return 0 if bien else 1 + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +sys.exit(main())