atuq §6.8: probado que el paquete sale por el proxy del contenedor — y con control negativo
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ
This commit is contained in:
@@ -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 `<longitud>:<json>`—, 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 `<longitud>:<json>`—,
|
||||
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 <cada .so> | 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
|
||||
|
||||
|
||||
@@ -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.)*
|
||||
|
||||
Executable
+279
@@ -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("<I", len(raw)) + lz4_literales(raw)
|
||||
|
||||
|
||||
def pestana(url, titulo, ucid):
|
||||
# `triggeringPrincipal_base64: vQ==` es el principal de SISTEMA serializado. SIN un principal
|
||||
# Gecko restaura la pestaña pero NO navega, y la prueba mediría un silencio en vez de un fallo.
|
||||
e = {"url": url, "title": titulo, "triggeringPrincipal_base64": "vQ=="}
|
||||
t = {"entries": [e], "index": 1, "hidden": False, "lastAccessed": 1, "searchMode": None}
|
||||
if ucid:
|
||||
t["userContextId"] = ucid
|
||||
return t
|
||||
|
||||
|
||||
def correr(tmp, atuq, segundos):
|
||||
guion = f"""
|
||||
mkdir -p /root /salida/perfil
|
||||
[ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /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())
|
||||
Reference in New Issue
Block a user