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:
Sergio
2026-09-06 23:47:08 +00:00
co-authored by Claude Opus 5
parent 7f912c1e22
commit 2e093f9cab
3 changed files with 320 additions and 165 deletions
+38 -160
View File
@@ -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
+3 -5
View File
@@ -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.)*
+279
View File
@@ -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())