Los llamadores EJECUTABLES: scripts/ (incluida toda la granja), los runbooks y CLAUDE.md. Seguro porque la 3a ya garantiza que el worker emite los dos binarios, y porque en farm-lab-sync.sh el cargo build remoto precede a la invocación remota en el mismo script. Verificado: sintaxis de los 49 (bash -n / py_compile — ojo que why-differs-barrido.sh es Python con extensión .sh) y `takana hash` devuelve hash real sobre el store. NO se toca en esta etapa, a propósito: - La variable de entorno HAMMER=. Es interfaz de los scripts entre sí y hay llamadores que la fijan; renombrarla va con la etapa 4. - docs/evidencia/ y los HANDOFF: son REGISTRO de lo que se corrió ese día. Reescribir un comando dentro de una evidencia la falsifica. - docs/state/: es generado, se regenera solo. - Los ADR y los docs de diseño: texto, y `hammer` sigue funcionando. Van con la etapa 5, que es la de churn de texto.
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 `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 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())
|