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
+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())