250 líneas de comentario en 151 scripts. Control verificado: el diff no toca NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa. El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces: las tres eran el MOTD que el script escribe DENTRO de la imagen construida — texto del producto, no comentario del script. Se cambiaron aparte y a propósito, que es rebranding, no limpieza. Y el hallazgo caro: casaba contra , que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate. La etapa 4 lo movió a y el script quedó casando NADA. No fallaba: imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja. Además 14 rutas de módulo en docs, que el barrido anterior no tocó porque no es frontera de palabra.
306 lines
14 KiB
Python
Executable File
306 lines
14 KiB
Python
Executable File
#!/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.
|
|
|
|
destino el de verdad; contesta un HTTP 200
|
|
proxy el de «Banco»; no habla SOCKS, sólo anota quién lo saludó
|
|
|
|
Los dos puertos los elige el KERNEL (bind al 0) y se atan en el hilo principal antes de empezar:
|
|
fijos, chocaban con lo que otro frente tuviera levantado en la misma máquina, y el choque se leía
|
|
como un fallo del navegador.
|
|
|
|
Dos pestañas piden la MISMA url. Si el ruteo funciona:
|
|
|
|
· la pestaña SIN contenedor ⇒ llega un `GET /directo` al DESTINO
|
|
· la pestaña en «Banco» ⇒ llega un saludo SOCKS5 (`\\x05…`) al PROXY, y NADA al destino
|
|
|
|
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 puerto del proxy 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/takana")
|
|
STORE = os.path.join(ROOT, "store")
|
|
# ── LOS PUERTOS LOS ELIGE EL SISTEMA, Y NO ES UN DETALLE ──────────────────────────────────────
|
|
# Estaban fijos en 8099/9099 y el 2026-09-07 esta prueba dio «NO concluyente» durante media hora por
|
|
# una razón que no tenía nada que ver con atuq: **otro agente de la misma máquina tenía levantado un
|
|
# `python3 -m http.server 8099`**. La oreja del destino no podía atarse, no llegaba nada, y el
|
|
# informe decía que la pestaña sin contenedor no había salido directa — o sea, un fallo del producto
|
|
# que no existía. En un repo que comparten varios frentes, un puerto fijo es estado compartido sin
|
|
# dueño. Ahora se pide el 0 y el kernel da uno libre.
|
|
|
|
visto = {"destino": [], "proxy": []}
|
|
|
|
|
|
def fatal(msg):
|
|
print(f"✗ {msg}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
# ── El artefacto se resuelve por `takana 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 oreja(etiqueta):
|
|
"""Un socket ATADO a un puerto libre, elegido por el kernel. Se ata acá, en el hilo principal.
|
|
|
|
Antes se ataba dentro del hilo, y ahí `fatal()` no puede matar el proceso: `SystemExit` en un
|
|
hilo secundario **sólo termina ese hilo**. El error se imprimía y la prueba seguía adelante
|
|
midiendo un puerto que nunca escuchó — que es peor que no medir, porque publica un veredicto."""
|
|
s = socket.socket()
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
s.bind(("127.0.0.1", 0))
|
|
except OSError as e:
|
|
fatal(f"no pude abrir la oreja de {etiqueta} ({e}) — la medición no valdría nada")
|
|
s.listen(8)
|
|
return s, s.getsockname()[1]
|
|
|
|
|
|
def oido(s, etiqueta, responder, hasta):
|
|
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",
|
|
"--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
|
|
"--setenv", "MOZ_DISABLE_UTILITY_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)}")
|
|
|
|
# Las dos orejas se atan ANTES de tocar nada: si no hay puertos, no hay prueba.
|
|
sock_destino, PUERTO_DESTINO = oreja("destino")
|
|
sock_proxy, PUERTO_PROXY = oreja("proxy")
|
|
URL = "http://127.0.0.1:%d" % PUERTO_DESTINO
|
|
print(f" orejas: destino {PUERTO_DESTINO} · proxy {PUERTO_PROXY}")
|
|
|
|
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 sk, et, resp in ((sock_destino, "destino", True), (sock_proxy, "proxy", False)):
|
|
threading.Thread(target=oido, args=(sk, 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 ({PUERTO_DESTINO}) llegaron {len(visto['destino'])}: "
|
|
f"{[d[:24] for d in visto['destino']]}")
|
|
print(f" al PROXY ({PUERTO_PROXY}) 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())
|