diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index 42e18465..587eefdf 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -269,7 +269,7 @@ La columna «quién más lo tiene» es lo que evita que nos contemos un cuento. | 6.5 | **Foco por `cortafuegos`, no por extensión** | nadie (nadie es dueño del navegador *y* del sistema) | `cortafuegos`, `pacha` | bajo | | 6.6 | **Medios por fuera del navegador** | extensiones sueltas; de fábrica no | `foreign-ytdlp`, `-platform`, `-dlna` | bajo | | 6.7 | **IA local en la barra lateral** | Chrome/Edge son nube; Zen no tiene | `rimay`, `iniy` | bajo | -| 6.8 | **Proxy por contenedor** | nadie de fábrica | — (API de Firefox) | bajo | +| 6.8 | **Proxy por contenedor** ✅ **v0.5** | nadie de fábrica | — (API de Firefox) | bajo | | 6.9 | **Torrent adentro** | **Vivaldi lo trae; Brave tuvo WebTorrent** | `shared/foreign-torrent` (SDD propio, sobre librqbit) | bajo | ### 6.1 `sct` es la joya @@ -309,6 +309,74 @@ eso; el modo incógnito es teatro. ⚠ Con la advertencia que el propio SDD de ` **hay que repetir acá, no esconder**: en una máquina con swap sin cifrar, el documento no aplica. Se promete lo que se puede probar. +### 6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06) + +**Es el primer diferenciador del §6 que se paga entero**, y se pudo pagar ahora porque es el único +de la lista que no pasa por el host del §7: es API de Firefox y nada más. Cada contenedor +—Personal, Trabajo, Banco, Compras— puede salir por su propio proxy. + +Tres piezas, en tres ficheros, y **ninguna alcanza sola**: + +| Pieza | Dónde | Qué aporta | +|---|---|---| +| `privacy.userContext.enabled` | `atuq.cfg` | prende los contenedores, que Firefox trae apagados | +| `Containers.Default` | `distribution/policies.json` | los CREA — es el único mecanismo que los pone en un perfil NUEVO | +| `3rdparty.Extensions` | `distribution/policies.json` | la config de fábrica, que la extensión lee como `storage.managed` | +| `proxy.onRequest` | `extensions/proxy/fondo.js` | lo único que ve el `cookieStoreId` de cada petición | + +**Las cuatro se comprobaron DENTRO del artefacto antes de escribir una línea**, que es la regla que +dejó el §2.sexies: `Containers` y `3rdparty` están en el `policies-schema.json` de `browser/omni.ja`, +`cookieStoreId` en el `schemas/proxy.json` de `omni.ja`, y `storage.managed` lee +`Services.policies.getExtensionPolicy(id)` en `ext-storage.js`. Ninguna de las cuatro se leyó de la +documentación de Mozilla: se leyeron de **nuestro** build, que es el que las tiene que traer. + +**Tres decisiones de diseño que valen más que el código:** + +1. **La configuración se indexa por NOMBRE de contenedor, no por `cookieStoreId`.** El id + (`firefox-container-3`) depende del ORDEN en que se crearon, así que la misma configuración + aplicada a otro perfil apuntaría a otro contenedor. Un identificador que cambia de significado + entre máquinas no sirve para configurar una distro. +2. **Fail closed.** Un contenedor que TIENE proxy configurado y no se pudo honrar —entrada + inválida, API que falla, mapa a medio construir— **no sale directo**: va a un destino cerrado y + el navegador muestra el error. Salir directo sería una fuga silenciosa, y es la misma familia + que el artefacto vacío de la regla 3: el fallo que llega hasta el final diciendo que todo fue + bien. Un usuario que pidió que «Banco» no salga por su línea tiene que ver un error, no navegar. +3. **`proxyDNS` viene PRENDIDO.** Sin él, Gecko resuelve el nombre por su cuenta antes de hablar + con el proxy: la consulta DNS sale justo por la línea que se quería evitar. Es la fuga clásica de + esta configuración, así que el default es el seguro y hay que apagarlo a mano. + +**Y lo que NO promete está en la propia página de opciones, arriba de todo y no en un pie**: +separación de tráfico, no anonimato; no toca la huella del navegador; para anonimato, Tor Browser. +Es el §4 cumplido en el sitio donde el usuario lo va a leer. + +#### Lo que quedó PROBADO, y con qué + +Corriendo el árbol de atuq en la misma jaula que usa `scripts/atuq-nested.sh`: + +| Afirmación | Prueba | +|---|---| +| 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 | +| 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 | + +**Lo que NO está probado, dicho por su nombre:** que una petición hecha en el contenedor «Banco» +salga por el proxy de «Banco». Eso pide automatizar la UI —una pestaña de contenedor no se abre +desde la línea de comandos— y queda pendiente en vez de darse por hecho. + +**Dos obstáculos del método, que valen para la próxima:** + +- **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 @@ -461,7 +529,8 @@ siguiente. | 6 | `sct` v1 (extensión + testigo) | el diferenciador que nadie tiene | 5 | | 7 | Descargas al CAS | 6.2, y alimenta 6.9 | 5 | | 8 | Archivo + RAG | 6.3 | 5, 7 | -| 9 | Proxy por contenedor, torrent, medios, foco | 6.5–6.9 | 5 | +| 9 | Torrent, medios, foco | 6.5–6.7, 6.9 | 5 | +| 9.a | **Proxy por contenedor ✅ v0.5** — contenedores por política + extensión con `proxy.onRequest` | 6.8, y NO dependía de 5: es API de Firefox | — | Las unidades 2 y 4 son **paralelizables**: la toolchain no toca el chrome y el chrome no toca la toolchain. Si hay dos frentes, van juntas. diff --git a/recipes/atuq.toml b/recipes/atuq.toml index fb615319..dcceb1da 100644 --- a/recipes/atuq.toml +++ b/recipes/atuq.toml @@ -31,8 +31,28 @@ # normalizado a 2010-01-01: Mozilla también persigue reproducibilidad. El re-empaque preserva # orden, método y fecha — el orden porque es el que Gecko lee al arrancar, y es lo que el # `jarlog` del PGO refinará cuando exista. +# ══ v0.5: PROXY POR CONTENEDOR (SDD 26 §6.8) ═══════════════════════════════════════════════════ +# El primer DIFERENCIADOR de la lista del §6 que se paga entero acá, sin depender del host de +# native messaging (§7) ni de nada de tawasuyu: es API de Firefox y nada más. Cada contenedor +# —Personal, Trabajo, Banco, Compras— puede salir por su propio proxy. +# +# Son tres piezas y ninguna alcanza sola: +# · `atuq.cfg` prende los contenedores (`privacy.userContext.enabled`), que vienen apagados; +# · `distribution/policies.json` los CREA con `Containers.Default` —el único mecanismo que los +# pone en un perfil nuevo— y deja la config de fábrica del proxy en `3rdparty.Extensions`; +# · `extensions/proxy/` los enruta con `proxy.onRequest`, que es lo único que ve el +# `cookieStoreId` de la petición. +# +# Las tres se comprobaron DENTRO del artefacto de firefox antes de escribirlas, no en la +# documentación de Mozilla: `Containers` y `3rdparty` están en el `policies-schema.json` de +# `browser/omni.ja`, y `cookieStoreId` en el `schemas/proxy.json` de `omni.ja`. Es la regla que dejó +# el §2.sexies: cuando la duda es «¿existe esto en NUESTRO build?», la respuesta está adentro. +# +# ⚠ LO QUE NO ES, Y VA ESCRITO EN LA PROPIA PÁGINA DE OPCIONES: separación de tráfico, NO anonimato. +# No toca la huella del navegador. El §4 del SDD ya se había comprometido a decirlo primero. name = "atuq" -version = "0.2.0" +# El campo se había quedado en 0.2.0 mientras los commits ya iban por v0.4; se pone al día acá. +version = "0.5.0" # El artefacto CONTIENE Firefox, así que hereda su licencia. El overlay de `recipes/atuq/` es # nuestro, pero eso no cambia lo que se distribuye. license = "MPL-2.0" @@ -107,6 +127,15 @@ for f in defaults/pref/autoconfig.js atuq.cfg chrome/atuq.css distribution/polic [ -s "$DST/$f" ] || { echo "atuq: $f quedó VACÍO o ausente" >&2; exit 1; } echo " $f ($(wc -c < "$DST/$f") bytes)" done +# Las extensiones de sistema: que el XPI EXISTA y tenga bytes. `rebrand.py` ya cruzó cada id contra +# `ExtensionSettings` y contra la ruta desde la que la política lo va a pedir; esto es el otro lado, +# el del fichero. Un `install_url` correcto apuntando a un XPI de cero bytes falla en el arranque +# del navegador, lejos de acá y con un mensaje que no nombra a esta receta. +for x in "$DST"/distribution/extensions/*.xpi; do + [ -s "$x" ] || { echo "atuq: el XPI $x quedó vacío o no existe" >&2; exit 1; } + echo " $(basename "$x") ($(wc -c < "$x") bytes)" +done + [ -x "$DST/atuq" ] || { echo "atuq: el binario renombrado no quedó ejecutable" >&2; exit 1; } [ -x /out/usr/bin/atuq ] || { echo "atuq: el lanzador no quedó ejecutable" >&2; exit 1; } grep -q '^Name=atuq' "$DST/application.ini" || { echo "atuq: application.ini no quedó rebrandeado" >&2; exit 1; } diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md index ab76eea7..c7f1f3f0 100644 --- a/recipes/atuq/README.md +++ b/recipes/atuq/README.md @@ -13,8 +13,9 @@ hashea por CONTENIDO. Editar un fichero de acá mueve el `ArtifactHash`; no hay | `prefs/autoconfig.js` | `defaults/pref/autoconfig.js` | Le dice a Gecko que lea `atuq.cfg`. Es el único gancho posible antes de que arranque el perfil. | | `atuq.cfg` | `/atuq.cfg` | Prefs de fábrica + carga la hoja de estilo del chrome. | | `chrome/atuq.css` | `/chrome/atuq.css` | El aspecto. Se inyecta como AGENT_SHEET desde `atuq.cfg`. | -| `distribution/policies.json` | `/distribution/policies.json` | Política de fábrica: telemetría, updates, **y la instalación de la extensión de inicio**. | -| `extension/` | `/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. | +| `distribution/policies.json` | `/distribution/policies.json` | Política de fábrica: telemetría, updates, **la instalación de las extensiones**, los CONTENEDORES y la config de fábrica del proxy. | +| `extensions/inicio/` | `/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. | +| `extensions/proxy/` | `/distribution/extensions/proxy@atuq.tawasuyu.xpi` | **Proxy por contenedor** (SDD 26 §6.8). | ## Por qué la extensión se instala por POLÍTICA y no por carpeta @@ -26,6 +27,46 @@ un fallo perfectamente silencioso. El mecanismo vigente es `ExtensionSettings` en `policies.json`, con `install_url` apuntando al XPI por ruta absoluta del FHS. Eso es estable acá porque el árbol se hidrata siempre en `/usr/lib/atuq`. +## Una extensión nueva no cuesta nada, y su id tiene un solo dueño + +`extensions//` con un `manifest.json` adentro y ya: `rebrand.py` recorre el directorio, +saca el id de `browser_specific_settings.gecko.id` —**no de una constante suya**— y escribe +`.xpi`. El icono lo inyecta desde `branding/icons/atuq128.png`, así que tampoco hay que copiarlo +en cada extensión: la v0.4 tenía ese PNG duplicado byte a byte y con dos extensiones habrían sido +tres copias que se desalinean en cuanto alguien actualice una sola. + +Y después **cruza los ids contra `policies.json`**, en los dos sentidos: una extensión empaquetada +que la política no declara, o una política que apunta a un XPI que no existe, matan el build. Lo +mismo con la ruta: el `install_url` tiene que ser exactamente donde el XPI quedó escrito. Antes eso +era una constante contra otra constante; ahora una de las dos se DERIVA del fichero real. + +`scripts/test-atuq-politica.py` le pone delante las cinco formas conocidas de romper ese cruce y +exige que las cinco maten el build (más un control con la política intacta, que tiene que pasar). + +## El proxy por contenedor (§6.8) + +Tres piezas, y ninguna alcanza sola: + +1. `atuq.cfg` prende los contenedores (`privacy.userContext.enabled`), que Firefox trae apagados; +2. `policies.json` los CREA con `Containers.Default` —el único mecanismo que los pone en un perfil + NUEVO— y deja la config de fábrica del proxy en `3rdparty.Extensions`, que es de dónde la + extensión lee su `storage.managed`; +3. `extensions/proxy/` los enruta con `proxy.onRequest`, que es lo único que ve el `cookieStoreId` + de cada petición. + +La config se escribe por **nombre de contenedor** y no por `cookieStoreId`: el id depende del orden +en que se crearon, así que la misma configuración aplicada a otro perfil apuntaría a otro +contenedor. Dos capas: `storage.managed` es la de fábrica (la distro) y `storage.local` la del +usuario, que pisa por contenedor. + +⚠ **Es separación de tráfico, NO anonimato**, y eso está escrito arriba de todo en la propia página +de opciones, no en un pie de página. No toca la huella del navegador. + +**Fail closed.** Un contenedor que TIENE proxy configurado y no se pudo honrar no sale directo: va a +un destino cerrado (127.0.0.1:9) y el navegador muestra el error. Salir directo sería una fuga +silenciosa, que es la peor forma de este fallo. Y `proxyDNS` viene prendido: sin él la consulta DNS +sale por la línea que se quería evitar. + ## Por qué el CSS no es `userChrome.css` `userChrome.css` vive en el PERFIL del usuario y exige que el usuario prenda @@ -55,7 +96,13 @@ y eso lo decide quien dibujó el zorro. ## Lo que TODAVÍA no está -- **Re-empaque de `omni.ja`**: es donde va el chrome de verdad (split view, dientes propios). Trae - dos condiciones ya sabidas: re-empacar determinista, y **preservar el orden del `jarlog`** cuando - el PGO exista, o se tira a la basura la optimización de arranque. +- **El chrome de verdad dentro de `omni.ja`** (split view, dientes propios). El re-empaque + determinista ya está resuelto y probado; lo que falta es el contenido. Sigue en pie la condición + 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. diff --git a/recipes/atuq/atuq.cfg b/recipes/atuq/atuq.cfg index ea148586..44afd70a 100644 --- a/recipes/atuq/atuq.cfg +++ b/recipes/atuq/atuq.cfg @@ -12,6 +12,25 @@ defaultPref("sidebar.revamp", true); defaultPref("sidebar.verticalTabs", true); +// ── LOS CONTENEDORES, PRENDIDOS DE FÁBRICA (SDD 26 §6.8) ─────────────────────────────────────── +// Firefox trae los contenedores en el motor desde hace años y los deja APAGADOS: para usarlos hay +// que prender una pref escondida o instalar la extensión de Mozilla. atuq los prende de fábrica +// porque son la mitad de abajo del proxy por contenedor —la que separa cookies, sesión y +// almacenamiento—; la otra mitad, la que separa por dónde SALEN los paquetes, la pone la extensión +// `proxy@atuq.tawasuyu`. +// +// Los contenedores CONCRETOS (Personal/Trabajo/Banco/Compras) no se declaran acá sino en +// `distribution/policies.json`, con la política `Containers.Default`: es el único mecanismo que +// crea contenedores en un perfil NUEVO sin que el usuario los escriba a mano. Comprobado dentro del +// artefacto, no en la documentación: `ContextualIdentityService.sys.mjs` los lee de +// `Services.policies.getActivePolicies().Containers.Default` en su `init()`. +// +// ⚠ `privacy.userContext.enabled` es la pref que ese mismo servicio observa: si alguna vez pasa a +// false, Gecko CIERRA las pestañas de contenedor y borra la lista. Va como `defaultPref` igual que +// todo lo demás —el usuario puede apagarlo—, pero apagarlo no es cosmético y por eso queda dicho. +defaultPref("privacy.userContext.enabled", true); +defaultPref("privacy.userContext.ui.enabled", true); + // La distro se actualiza con hammer. Un navegador que se auto-actualiza por su cuenta rompería el // invariante entero: el binario dejaría de ser el que el artefacto dice que es. defaultPref("app.update.auto", false); diff --git a/recipes/atuq/distribution/policies.json b/recipes/atuq/distribution/policies.json index 4fe60e28..49994ed2 100644 --- a/recipes/atuq/distribution/policies.json +++ b/recipes/atuq/distribution/policies.json @@ -7,10 +7,34 @@ "DisableProfileImport": false, "OverrideFirstRunPage": "", "OverridePostUpdatePage": "", + "Containers": { + "Default": [ + { "name": "Personal", "icon": "fingerprint", "color": "orange" }, + { "name": "Trabajo", "icon": "briefcase", "color": "blue" }, + { "name": "Banco", "icon": "dollar", "color": "green" }, + { "name": "Compras", "icon": "cart", "color": "pink" } + ] + }, + "3rdparty": { + "Extensions": { + "proxy@atuq.tawasuyu": { + "proxies": { + "Personal": { "type": "direct" }, + "Trabajo": { "type": "direct" }, + "Banco": { "type": "direct" }, + "Compras": { "type": "direct" } + } + } + } + }, "ExtensionSettings": { "inicio@atuq.tawasuyu": { "installation_mode": "normal_installed", "install_url": "file:///usr/lib/atuq/distribution/extensions/inicio@atuq.tawasuyu.xpi" + }, + "proxy@atuq.tawasuyu": { + "installation_mode": "normal_installed", + "install_url": "file:///usr/lib/atuq/distribution/extensions/proxy@atuq.tawasuyu.xpi" } } } diff --git a/recipes/atuq/extensions/proxy/fondo.js b/recipes/atuq/extensions/proxy/fondo.js new file mode 100644 index 00000000..3495a310 --- /dev/null +++ b/recipes/atuq/extensions/proxy/fondo.js @@ -0,0 +1,208 @@ +// atuq — proxy por contenedor (SDD 26 §6.8). +// +// QUÉ HACE: cada contenedor de Firefox («contextual identity») puede salir por su propio proxy. +// El contenedor «Banco» por un SOCKS de la oficina, el «Compras» directo, y ninguno de los dos se +// entera del otro. Ningún navegador trae esto de fábrica: en todos hace falta instalar una +// extensión de terceros, que es exactamente el eslabón que una distro no debería pedirle al usuario. +// +// ⚠ LO QUE ESTO **NO** ES, Y VA PRIMERO A PROPÓSITO (SDD 26 §4): esto es **separación de tráfico, +// no anonimato**. Cambia por dónde salen los paquetes; no toca la huella del navegador, ni el +// user-agent, ni los timers, ni el canvas. Un sitio sigue reconociendo el mismo atuq del otro lado +// de dos proxies distintos. Para anonimato existe Tor Browser y lo decimos nosotros primero. +// +// ── POR QUÉ MV2 CON FONDO PERSISTENTE ───────────────────────────────────────────────────────── +// Un event page se descarga cuando está ocioso y se despierta con el evento. Acá eso significaría +// reconstruir el mapa nombre→contenedor DENTRO del camino crítico de la primera petición, con la +// resolución del proxy esperando. Persistente cuesta unos MB y quita ese riesgo entero. + +// ── EL MURO ─────────────────────────────────────────────────────────────────────────────────── +// Adónde se manda un contenedor que TIENE proxy configurado y no lo pudimos honrar (entrada +// inválida, API que falla, mapa a medio construir). No es «directo»: si alguien pidió que ese +// contenedor no salga por su línea, la respuesta correcta a un fallo nuestro es que NO SALGA, y que +// se vea. El puerto 9 es `discard`, cerrado en cualquier máquina normal ⇒ el navegador muestra un +// error de proxy en la cara en vez de mandar el tráfico por donde el usuario no quería. +// +// Es la misma postura que el resto del repo: un ausente falla ruidosamente, un vacío llega hasta el +// final diciendo que todo fue bien. Acá el «vacío» sería una fuga silenciosa. +const MURO = { type: "socks", host: "127.0.0.1", port: 9, proxyDNS: true }; +const DIRECTO = { type: "direct" }; + +const TIPOS = new Set(["direct", "http", "https", "socks", "socks4"]); + +// Estado, reconstruible entero: nombre de contenedor → ProxyInfo ya validado. +let porNombre = new Map(); +// cookieStoreId → ProxyInfo. Es el que consulta el listener; se recalcula cuando cambia cualquiera +// de los dos lados (la config o la lista de contenedores). +let porContenedor = new Map(); + +function aviso(msg, e) { + console.error(`atuq/proxy: ${msg}${e ? ` — ${e}` : ""}`); +} + +// ── VALIDACIÓN ──────────────────────────────────────────────────────────────────────────────── +// Devuelve el ProxyInfo, o `null` si la entrada no sirve. Nunca «arregla» una entrada a medias: +// una entrada rota y una entrada ausente son cosas distintas y el llamador las trata distinto —la +// ausente es directo, la rota es el MURO—, así que confundirlas acá borraría esa diferencia. +function validar(entrada) { + if (!entrada || typeof entrada !== "object") return null; + const tipo = entrada.type; + if (!TIPOS.has(tipo)) return null; + if (tipo === "direct") return DIRECTO; + const host = entrada.host; + const port = Number(entrada.port); + if (typeof host !== "string" || !host.trim()) return null; + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + const info = { type: tipo, host: host.trim(), port }; + // `proxyDNS` sólo existe para SOCKS, y NO es cosmético: sin él el navegador resuelve el nombre + // por su cuenta ANTES de hablar con el proxy, así que la consulta DNS sale por la línea que se + // quería evitar. Es la fuga clásica de este tipo de configuración; por eso el default es `true` + // y hay que apagarlo a mano, no al revés. + if (tipo === "socks" || tipo === "socks4") { + info.proxyDNS = entrada.proxyDNS !== false; + } + return info; +} + +// ── LA CONFIGURACIÓN: DOS CAPAS ─────────────────────────────────────────────────────────────── +// `storage.managed` es la de FÁBRICA y la pone la distro por política (`3rdparty.Extensions` en +// `distribution/policies.json`); `storage.local` es la del USUARIO y pisa a la anterior por +// contenedor. Es la misma postura que `defaultPref` en `atuq.cfg`: la distro propone un arranque, +// no una cárcel. +// +// `storage.managed.get()` LANZA «Managed storage manifest not found» cuando no hay política —no +// devuelve vacío—, así que el catch no es decorativo: sin él, no tener política rompería la capa +// del usuario también. +async function leerConfig() { + const fusion = new Map(); + const invalidas = []; + + const capas = []; + try { + capas.push(await browser.storage.managed.get("proxies")); + } catch (e) { + // Normal en un perfil sin política; se anota en la consola y se sigue. + console.info(`atuq/proxy: sin configuración de fábrica (${e})`); + } + try { + capas.push(await browser.storage.local.get("proxies")); + } catch (e) { + aviso("no pude leer storage.local", e); + } + + for (const capa of capas) { + const proxies = capa && capa.proxies; + if (!proxies || typeof proxies !== "object") continue; + for (const [nombre, entrada] of Object.entries(proxies)) { + const info = validar(entrada); + if (info) { + fusion.set(nombre, info); + } else { + // Se RECUERDA la entrada rota en vez de ignorarla: el contenedor queda al MURO, que es lo + // que la vuelve visible. Ignorarla lo dejaría saliendo directo, que es la fuga. + fusion.set(nombre, MURO); + invalidas.push(nombre); + } + } + } + if (invalidas.length) { + aviso(`entradas inválidas, esos contenedores quedan al muro: ${invalidas.join(", ")}`); + } + return fusion; +} + +// ── EL MAPA CONTRA LOS CONTENEDORES REALES ──────────────────────────────────────────────────── +// La configuración se escribe por NOMBRE («Banco») y no por `cookieStoreId` +// («firefox-container-3»), por dos razones: el nombre es lo que el usuario ve y lo que la política +// de la distro declara, y el id depende del ORDEN en que se crearon los contenedores, o sea que la +// misma config aplicada a otro perfil apuntaría a otro contenedor. Un identificador que cambia de +// significado entre máquinas no sirve para configurar una distro. +async function reconstruir() { + const mapa = new Map(); + let identidades = []; + try { + identidades = await browser.contextualIdentities.query({}); + } catch (e) { + // Pasa si los contenedores están apagados (`privacy.userContext.enabled=false`). Sin + // contenedores no hay nada que enrutar y el listener manda todo directo, que es correcto: no + // hay tráfico «de un contenedor» que pueda fugarse. + aviso("no pude listar los contenedores", e); + } + const sinContenedor = new Set(porNombre.keys()); + for (const id of identidades) { + const info = porNombre.get(id.name); + if (info) { + mapa.set(id.cookieStoreId, info); + sinContenedor.delete(id.name); + } + } + porContenedor = mapa; + if (sinContenedor.size) { + // No es un error: se puede configurar un contenedor que todavía no existe. Pero es la causa + // número uno de «configuré el proxy y no pasa nada», así que se dice por su nombre. + console.info( + `atuq/proxy: configurados sin contenedor que les corresponda: ${[...sinContenedor].join(", ")}` + ); + } + console.info(`atuq/proxy: ${mapa.size} contenedor(es) enrutado(s)`); +} + +// `listo` es la promesa del arranque. El listener la espera: así no hay ventana en la que una +// petición temprana se resuelva con el mapa a medio llenar. +let listo = recargar(); + +async function recargar() { + porNombre = await leerConfig(); + await reconstruir(); +} + +// ── EL LISTENER, REGISTRADO YA ──────────────────────────────────────────────────────────────── +// Se registra en el primer tick, ANTES de que la configuración esté leída, y a propósito: si se +// registrara después del `await`, las peticiones de ese intervalo saldrían directas sin pasar por +// acá. Registrado desde el principio, esas peticiones simplemente esperan a `listo`. +// +// ⚠ Lo que esto NO cierra, dicho: las peticiones que Gecko haga ANTES de que la extensión arranque +// (arranque en frío, actualización de la extensión) no ven este listener. Es un límite del +// mecanismo de extensión, no de esta implementación; cerrarlo del todo exige que el ruteo viva en +// el motor. Se anota acá porque un límite que no está escrito se lee como que no existe. +browser.proxy.onRequest.addListener( + async details => { + try { + await listo; + } catch (e) { + aviso("el arranque falló", e); + // Si ni siquiera pudimos leer la configuración no sabemos si este contenedor tenía proxy ⇒ + // no se puede afirmar que salir directo sea seguro, pero tampoco se puede mandar al muro el + // navegador entero. Se manda al muro sólo lo que tiene contenedor, que es de lo único que + // alguien pudo haber pedido separación. + return esDeContenedor(details) ? MURO : DIRECTO; + } + const id = details.cookieStoreId; + if (!id) return DIRECTO; + return porContenedor.get(id) || DIRECTO; + }, + { urls: [""] } +); + +function esDeContenedor(details) { + return typeof details.cookieStoreId === "string" && details.cookieStoreId.startsWith("firefox-container-"); +} + +// `onError` es lo que convierte «no anda» en un mensaje. Sin esto, un fallo del propio resolvedor +// de proxy no aparece en ningún lado. +browser.proxy.onError.addListener(e => aviso("el resolvedor falló", e)); + +// Los contenedores se crean, se renombran y se borran en caliente, y la config está indexada por +// NOMBRE ⇒ renombrar uno cambia a qué proxy corresponde. Se reconstruye el mapa en los tres casos. +for (const ev of ["onCreated", "onUpdated", "onRemoved"]) { + browser.contextualIdentities[ev].addListener(() => { + listo = reconstruir(); + }); +} + +// La página de opciones escribe en `storage.local`; esto es lo que hace que el cambio valga sin +// reiniciar el navegador. +browser.storage.onChanged.addListener((cambios, area) => { + if (area === "local" && "proxies" in cambios) { + listo = recargar(); + } +}); diff --git a/recipes/atuq/extensions/proxy/manifest.json b/recipes/atuq/extensions/proxy/manifest.json new file mode 100644 index 00000000..548a80e6 --- /dev/null +++ b/recipes/atuq/extensions/proxy/manifest.json @@ -0,0 +1,27 @@ +{ + "manifest_version": 2, + "name": "atuq proxy por contenedor", + "version": "1.0", + "description": "Cada contenedor sale por su propio proxy. Separación de tráfico, NO anonimato.", + "browser_specific_settings": { + "gecko": { + "id": "proxy@atuq.tawasuyu", + "strict_min_version": "128.0" + } + }, + "icons": { "128": "atuq128.png" }, + "permissions": [ + "proxy", + "storage", + "contextualIdentities", + "" + ], + "background": { + "scripts": ["fondo.js"], + "persistent": true + }, + "options_ui": { + "page": "opciones.html", + "open_in_tab": true + } +} diff --git a/recipes/atuq/extensions/proxy/opciones.html b/recipes/atuq/extensions/proxy/opciones.html new file mode 100644 index 00000000..a33dce07 --- /dev/null +++ b/recipes/atuq/extensions/proxy/opciones.html @@ -0,0 +1,103 @@ + + + + +atuq — proxy por contenedor + + + +
+

Proxy por contenedor

+

Cada contenedor sale por su propia línea. Lo que hagas en uno no viaja por la del otro.

+ +

+ Esto es separación de tráfico, no anonimato. Cambia por dónde salen los paquetes; no + cambia la huella del navegador. Un sitio reconoce el mismo atuq del otro lado de dos proxies + distintos. Si lo que buscás es anonimato, lo honesto es decirte que uses Tor Browser, y + te lo decimos nosotros primero. +

+ + + + + + +
ContenedorSalidaServidorPuertoDNS por el proxy
+ + +
+ + + +
+
+ + + diff --git a/recipes/atuq/extensions/proxy/opciones.js b/recipes/atuq/extensions/proxy/opciones.js new file mode 100644 index 00000000..44744c13 --- /dev/null +++ b/recipes/atuq/extensions/proxy/opciones.js @@ -0,0 +1,186 @@ +// Página de opciones del proxy por contenedor. +// +// DOS CAPAS, IGUAL QUE EN `fondo.js`: lo que la distro deja de fábrica llega por +// `storage.managed` (política de empresa, `3rdparty.Extensions` en `distribution/policies.json`) y +// es de SÓLO LECTURA para esta página; lo que el usuario decide se guarda en `storage.local` y pisa +// a lo anterior contenedor por contenedor. «Volver a los de fábrica» no reescribe valores: BORRA la +// capa del usuario, que es lo único que deja el resultado igual a un perfil recién creado. + +const TIPOS = [ + ["direct", "Directo (sin proxy)"], + ["socks", "SOCKS5"], + ["socks4", "SOCKS4"], + ["http", "HTTP"], + ["https", "HTTPS"], +]; + +const filas = document.getElementById("filas"); +const vacio = document.getElementById("vacio"); +const estado = document.getElementById("estado"); + +function decir(msg, error) { + estado.textContent = msg; + estado.style.color = error ? "#c15728" : ""; +} + +async function leerCapa(area, nombre) { + try { + const r = await browser.storage[area].get("proxies"); + return (r && r.proxies) || {}; + } catch (e) { + // `storage.managed` LANZA cuando no hay política; no es un fallo, es la ausencia de la capa. + console.info(`atuq/proxy: sin capa ${nombre} (${e})`); + return {}; + } +} + +function fila(identidad, deFabrica, delUsuario) { + const valor = delUsuario || deFabrica || { type: "direct" }; + const tr = document.createElement("tr"); + tr.dataset.nombre = identidad.name; + + const tdNombre = document.createElement("td"); + const cont = document.createElement("div"); + cont.className = "nombre"; + const punto = document.createElement("span"); + punto.className = "punto"; + // `colorCode` viene del propio contenedor: el punto de esta tabla y la raya de la pestaña son el + // mismo color porque salen del mismo sitio, no porque los hayamos igualado a ojo. + punto.style.background = identidad.colorCode || "#888"; + cont.append(punto, document.createTextNode(identidad.name)); + if (deFabrica && !delUsuario) { + const et = document.createElement("span"); + et.className = "fabrica"; + et.textContent = "de fábrica"; + et.title = "Viene de la política de la distro; si lo cambiás, tu valor manda."; + cont.append(et); + } + tdNombre.append(cont); + + const tdTipo = document.createElement("td"); + const sel = document.createElement("select"); + for (const [v, etiqueta] of TIPOS) { + const o = document.createElement("option"); + o.value = v; + o.textContent = etiqueta; + sel.append(o); + } + sel.value = TIPOS.some(([v]) => v === valor.type) ? valor.type : "direct"; + tdTipo.append(sel); + + const tdHost = document.createElement("td"); + const host = document.createElement("input"); + host.type = "text"; + host.className = "host"; + host.placeholder = "127.0.0.1"; + host.value = valor.host || ""; + tdHost.append(host); + + const tdPort = document.createElement("td"); + const port = document.createElement("input"); + port.type = "number"; + port.className = "port"; + port.min = "1"; + port.max = "65535"; + port.placeholder = "1080"; + port.value = valor.port || ""; + tdPort.append(port); + + const tdDns = document.createElement("td"); + tdDns.className = "dns"; + const dns = document.createElement("input"); + dns.type = "checkbox"; + // Prendido por defecto y no apagado: sin esto el navegador resuelve el nombre por su cuenta antes + // de hablar con el proxy y la consulta DNS sale por la línea que se quería evitar. Es la fuga + // clásica de esta configuración, así que el default es el seguro. + dns.checked = valor.proxyDNS !== false; + dns.title = "Resolver los nombres en el proxy y no en esta máquina. Sólo aplica a SOCKS."; + tdDns.append(dns); + + const sincronizar = () => { + const directo = sel.value === "direct"; + const socks = sel.value === "socks" || sel.value === "socks4"; + host.disabled = directo; + port.disabled = directo; + dns.disabled = !socks; + }; + sel.addEventListener("change", sincronizar); + sincronizar(); + + tr.append(tdNombre, tdTipo, tdHost, tdPort, tdDns); + tr.leer = () => { + const tipo = sel.value; + if (tipo === "direct") return { type: "direct" }; + const p = Number(port.value); + if (!host.value.trim()) throw new Error(`«${identidad.name}»: falta el servidor`); + if (!Number.isInteger(p) || p < 1 || p > 65535) { + throw new Error(`«${identidad.name}»: el puerto tiene que estar entre 1 y 65535`); + } + const info = { type: tipo, host: host.value.trim(), port: p }; + if (tipo === "socks" || tipo === "socks4") info.proxyDNS = dns.checked; + return info; + }; + return tr; +} + +async function pintar() { + filas.textContent = ""; + + // ── «NO HAY CONTENEDORES» Y «NO HAY API» SE PARECEN Y NO SON LO MISMO ──────────────────────── + // Medido, no supuesto: abriendo esta página mientras el arranque de las extensiones todavía corre + // —pasa en un arranque en frío, y pasó de verdad en la prueba headless— el global `browser` aún + // no está inyectado. Sin este corte, cada `await` de abajo tira `ReferenceError`, los catch lo + // tragan y la página termina diciendo «No hay contenedores en este perfil»: un mensaje FALSO que + // manda a mirar al sitio equivocado. Un ausente tiene que fallar ruidoso. + if (typeof browser === "undefined" || !browser.contextualIdentities) { + vacio.hidden = true; + decir("Las APIs de la extensión todavía no están listas — recargá la página en un momento.", true); + return; + } + const [deFabrica, delUsuario] = await Promise.all([ + leerCapa("managed", "de fábrica"), + leerCapa("local", "del usuario"), + ]); + + let identidades = []; + try { + identidades = await browser.contextualIdentities.query({}); + } catch (e) { + decir(`No pude listar los contenedores: ${e}`, true); + } + vacio.hidden = identidades.length > 0; + for (const id of identidades) { + filas.append(fila(id, deFabrica[id.name], delUsuario[id.name])); + } + + // Un proxy configurado para un contenedor que no existe es la causa número uno de «lo configuré y + // no pasa nada» — casi siempre porque el contenedor se renombró. Se dice acá, no en la consola. + const nombres = new Set(identidades.map(i => i.name)); + const huerfanos = [...new Set([...Object.keys(deFabrica), ...Object.keys(delUsuario)])] + .filter(n => !nombres.has(n)); + if (huerfanos.length) { + decir(`Configurados sin contenedor que les corresponda: ${huerfanos.join(", ")}`, true); + } +} + +document.getElementById("guardar").addEventListener("click", async () => { + const proxies = {}; + try { + for (const tr of filas.children) proxies[tr.dataset.nombre] = tr.leer(); + } catch (e) { + // No se guarda NADA si una fila está mal. Guardar las buenas y saltar la rota dejaría al + // usuario creyendo que guardó, con un contenedor saliendo por donde no quería. + decir(e.message, true); + return; + } + await browser.storage.local.set({ proxies }); + decir("Guardado. Vale para las peticiones nuevas, sin reiniciar."); +}); + +document.getElementById("restablecer").addEventListener("click", async () => { + await browser.storage.local.remove("proxies"); + await pintar(); + decir("Listo: quedó lo que trae la distro de fábrica."); +}); + +pintar(); diff --git a/recipes/atuq/tools/rebrand.py b/recipes/atuq/tools/rebrand.py index c53d2322..773d9664 100644 --- a/recipes/atuq/tools/rebrand.py +++ b/recipes/atuq/tools/rebrand.py @@ -21,6 +21,7 @@ igual de bien: sería exactamente la clase de fallo que llega hasta el final dic bien (regla 3 del CLAUDE.md). """ import hashlib +import json import os import sys import zipfile @@ -228,48 +229,140 @@ def reemplazos_branding(): return r -# ── 4.bis La extensión de sistema: página de inicio y pestaña nueva ──────────────────────────── -# POR QUÉ UNA EXTENSIÓN Y NO UNA PREF. `browser.startup.homepage` acepta una URL, pero la pestaña -# NUEVA no se puede redirigir con una pref desde hace años: el único mecanismo soportado es -# `chrome_url_overrides.newtab`. Y una página servida por `file://` mostraría una ruta del disco en -# la barra de direcciones, que es feo y además frágil (depende de dónde quedó hidratado el árbol). -# La extensión resuelve las dos —`chrome_url_overrides` y `chrome_settings_overrides.homepage`— con -# una sola pieza y sin URLs absolutas. +# ── 4.bis Las extensiones de sistema ────────────────────────────────────────────────────────── +# POR QUÉ EXTENSIONES Y NO PREFS. La página de inicio se podría poner con +# `browser.startup.homepage`, pero la pestaña NUEVA no se puede redirigir con una pref desde hace +# años: el único mecanismo soportado es `chrome_url_overrides.newtab`. Y el ruteo por proxy de cada +# contenedor (SDD 26 §6.8) directamente no tiene forma de pref: es la API `proxy.onRequest`, que +# sólo existe para una extensión. # # Esto sólo funciona porque `recipes/firefox.toml` construye con -# `--with-unsigned-addon-scopes=app,system` y `atuq.cfg` pone `extensions.autoDisableScopes = 0`. -# Son tres piezas en tres ficheros distintos y **las tres tienen que estar**: sin la primera Gecko -# rechaza el XPI sin firmar, sin la segunda lo instala DESACTIVADO. +# `--with-unsigned-addon-scopes=app,system` y `atuq.cfg` pone `extensions.autoDisableScopes = 0` y +# `xpinstall.signatures.required = false`. Son piezas en ficheros distintos y **todas tienen que +# estar**: sin la primera Gecko rechaza el XPI sin firmar, sin la segunda lo instala DESACTIVADO. # # EL ZIP VA `STORED` Y CON FECHA FIJA, igual que el `omni.ja`: un XPI comprimido metería la versión -# de zlib como variable del artefacto, y son cuatro ficheros de texto — no hay nada que ahorrar. -EXT_ID = "inicio@atuq.tawasuyu" +# de zlib como variable del artefacto, y son ficheros de texto — no hay nada que ahorrar. +# +# ── EL ID SALE DEL MANIFIESTO, NO DE UNA CONSTANTE DE ACÁ ───────────────────────────────────── +# La v0.4 tenía el id escrito en dos sitios: `EXT_ID` en este fichero y +# `browser_specific_settings.gecko.id` en el manifiesto. Dos dueños para un valor es la forma +# conocida de que se desincronicen, y el fallo que producen es de los caros: el XPI se escribiría +# con un nombre y Gecko, al abrirlo, encontraría otro id adentro ⇒ la política de instalación +# apuntaría a un fichero que para Firefox no es el complemento que pidió. Ahora hay un solo dueño +# —el manifiesto— y el nombre del fichero se DERIVA de él. +EXT_DIR = "extensions" +# El icono se INYECTA desde `branding/icons/` en vez de vivir copiado dentro de cada extensión. La +# v0.4 tenía `extension/atuq128.png` byte a byte igual a `branding/icons/atuq128.png`; con dos +# extensiones habrían sido tres copias del mismo PNG en el repo, y la marca se desalinea el día que +# alguien actualiza una y no las otras. +EXT_ICONO = "atuq128.png" -def empaquetar_extension(): - origen = os.path.join(OVERLAY, "extension") - if not os.path.isdir(origen): - fatal(f"no está el árbol de la extensión en {origen}") - ficheros = sorted(os.listdir(origen)) - if "manifest.json" not in ficheros: - fatal("la extensión no tiene manifest.json") +def _leer_id(manifiesto, donde): + try: + datos = json.loads(manifiesto) + except ValueError as e: + fatal(f"{donde}: el manifest.json no es JSON válido — {e}") + try: + ext_id = datos["browser_specific_settings"]["gecko"]["id"] + except (KeyError, TypeError): + fatal(f"{donde}: el manifest.json no declara browser_specific_settings.gecko.id") + if not isinstance(ext_id, str) or "@" not in ext_id: + fatal(f"{donde}: el id {ext_id!r} no parece un id de extensión") + return ext_id + + +def empaquetar_extensiones(): + base = os.path.join(OVERLAY, EXT_DIR) + if not os.path.isdir(base): + fatal(f"no está el árbol de las extensiones en {base}") + dirs = sorted(d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d))) + if not dirs: + fatal(f"{base} no tiene ninguna extensión") + + with open(os.path.join(OVERLAY, "branding", "icons", EXT_ICONO), "rb") as fh: + icono = fh.read() + destino_dir = os.path.join(APPDIR, "distribution", "extensions") os.makedirs(destino_dir, exist_ok=True) - destino = os.path.join(destino_dir, f"{EXT_ID}.xpi") - with zipfile.ZipFile(destino, "w") as z: - for nombre in ficheros: + + resultados = [] + for d in dirs: + origen = os.path.join(base, d) + ficheros = {} + for nombre in sorted(os.listdir(origen)): ruta = os.path.join(origen, nombre) if not os.path.isfile(ruta): continue with open(ruta, "rb") as fh: datos = fh.read() if not datos: - fatal(f"la extensión trae {nombre} VACÍO") - zi = zipfile.ZipInfo(nombre, date_time=(2010, 1, 1, 0, 0, 0)) - zi.compress_type = zipfile.ZIP_STORED - zi.external_attr = 0o644 << 16 - z.writestr(zi, datos) - return f"{EXT_ID}.xpi ({len(ficheros)} ficheros, {os.path.getsize(destino)} bytes)" + fatal(f"la extensión {d} trae {nombre} VACÍO") + ficheros[nombre] = datos + if "manifest.json" not in ficheros: + fatal(f"la extensión {d} no tiene manifest.json") + ficheros[EXT_ICONO] = icono + + ext_id = _leer_id(ficheros["manifest.json"], d) + destino = os.path.join(destino_dir, f"{ext_id}.xpi") + # Orden alfabético y no el del `listdir`: el orden del directorio depende del sistema de + # ficheros, y un zip cuyas entradas cambian de orden entre máquinas ya no reproduce. + with zipfile.ZipFile(destino, "w") as z: + for nombre in sorted(ficheros): + zi = zipfile.ZipInfo(nombre, date_time=(2010, 1, 1, 0, 0, 0)) + zi.compress_type = zipfile.ZIP_STORED + zi.external_attr = 0o644 << 16 + z.writestr(zi, ficheros[nombre]) + # La ruta con la que la POLÍTICA va a pedir este XPI, derivada de dónde lo acabamos de + # escribir. Es lo que hace que el cruce de abajo compruebe algo de verdad y no una constante + # contra otra constante. + url = "file:///" + os.path.relpath(destino, OUTROOT) + resultados.append((d, ext_id, len(ficheros), os.path.getsize(destino), url)) + return resultados + + +# ── 4.ter El cruce contra la política ───────────────────────────────────────────────────────── +# El sideloading desde `distribution/extensions/` ya no existe en Firefox: el XPI se instala porque +# `policies.json` lo declara en `ExtensionSettings` con un `install_url` ABSOLUTO. O sea que el +# fichero y la política tienen que coincidir en id Y en ruta, y cuando no coinciden el fallo es +# silencioso —el complemento sencillamente no aparece— o llega como un `Download failed` que no +# nombra la causa. Se cruza acá, en el build, donde falla gratis. +def verificar_politica(resultados): + ruta = os.path.join(APPDIR, "distribution", "policies.json") + try: + with open(ruta, encoding="utf-8") as fh: + pol = json.load(fh)["policies"] + except (OSError, ValueError, KeyError) as e: + fatal(f"no pude leer {ruta}: {e}") + + ajustes = pol.get("ExtensionSettings", {}) + declarados = {k for k in ajustes if k != "*"} + empaquetados = {r[1] for r in resultados} + + faltan = empaquetados - declarados + if faltan: + fatal(f"empaquetadas pero NO declaradas en ExtensionSettings: {sorted(faltan)}") + sobran = declarados - empaquetados + if sobran: + fatal(f"declaradas en ExtensionSettings sin XPI que las respalde: {sorted(sobran)}") + + for _, ext_id, _, _, url in resultados: + esperada = ajustes[ext_id].get("install_url") + if esperada != url: + fatal(f"{ext_id}: la política instala desde {esperada!r} y el XPI quedó en {url!r}") + + # Los contenedores de la política son la mitad de abajo del §6.8: sin ellos, la extensión del + # proxy arranca, no encuentra a qué contenedor aplicar nada y queda inerte sin decir por qué. + contenedores = [c.get("name") for c in pol.get("Containers", {}).get("Default", [])] + terceros = pol.get("3rdparty", {}).get("Extensions", {}) + for ext_id, conf in terceros.items(): + if ext_id not in empaquetados: + fatal(f"3rdparty configura {ext_id}, que no es ninguna extensión de atuq") + huerfanos = sorted(set(conf.get("proxies", {})) - set(contenedores)) + if huerfanos: + fatal(f"{ext_id}: proxies para contenedores que la política no crea: {huerfanos}") + return contenedores # ── 5. Los binarios ──────────────────────────────────────────────────────────────────────────── @@ -334,7 +427,8 @@ def main(): binarios = renombrar_binarios() # Después del renombrado: los ficheros ya se llaman atuq/atuq-bin. parches = [rebrandear_binario(MARCA), rebrandear_binario(f"{MARCA}-bin")] - ext = empaquetar_extension() + exts = empaquetar_extensiones() + contenedores = verificar_politica(exts) print(f"rebrand: overlay {huella[:16]}… ⇒ BuildID {bid}") print(f"rebrand: application.ini {sorted(claves)}") @@ -344,7 +438,9 @@ def main(): print(f"rebrand: binarios {', '.join(binarios)} (+ symlink firefox → {MARCA})") for l in parches: print(f"rebrand: {l}") - print(f"rebrand: extensión de sistema {ext}") + for d, ext_id, n, size, _ in exts: + print(f"rebrand: extensión de sistema {ext_id}.xpi ← {EXT_DIR}/{d} ({n} ficheros, {size} bytes)") + print(f"rebrand: política cruzada — {len(exts)} extensión(es), contenedores {contenedores}") main() diff --git a/scripts/test-atuq-politica.py b/scripts/test-atuq-politica.py new file mode 100755 index 00000000..67387762 --- /dev/null +++ b/scripts/test-atuq-politica.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Ejercita los guardianes de `recipes/atuq/tools/rebrand.py` con políticas ROTAS a propósito. + + python3 scripts/test-atuq-politica.py # sale 0 si todos los guardianes hacen su trabajo + +POR QUÉ EXISTE. `atuq` instala sus extensiones por POLÍTICA (`ExtensionSettings` en +`distribution/policies.json`), no por sideloading —Firefox lo retiró—, así que el XPI y la política +tienen que coincidir en id y en RUTA ABSOLUTA. Cuando no coinciden, el fallo es silencioso: el +complemento sencillamente no aparece, o llega un `Download failed` que no nombra la causa. Los +guardianes que cruzan las dos cosas viven en `rebrand.py`; esto comprueba QUE FALLEN. + +Un guardián que nunca falló no se sabe si sirve. Cada caso de abajo es una forma conocida de +desincronizarlas, y el control con la política intacta está primero para que un guardián que muera +SIEMPRE —que es igual de inútil— también se note. + +No construye nada ni toca el store: arma un appdir de mentira en un temporal y llama a las dos +funciones directamente. +""" +import json, os, pathlib, shutil, sys, tempfile + +REPO = str(pathlib.Path(__file__).resolve().parent.parent) +os.chdir(REPO) +FUENTE = open("recipes/atuq/tools/rebrand.py").read().replace("\nmain()\n", "\n") + +def correr(mutar): + raiz = tempfile.mkdtemp() + app = os.path.join(raiz, "usr", "lib", "atuq") + os.makedirs(os.path.join(app, "distribution")) + pol = json.load(open("recipes/atuq/distribution/policies.json")) + mutar(pol) + json.dump(pol, open(os.path.join(app, "distribution", "policies.json"), "w")) + ns = {"__name__": "rb"} + sys.argv = ["rebrand.py", app, os.path.join(REPO, "recipes/atuq"), raiz] + exec(compile(FUENTE, "rebrand.py", "exec"), ns) + try: + ns["verificar_politica"](ns["empaquetar_extensiones"]()) + except SystemExit: + return "MURIÓ" + finally: + shutil.rmtree(raiz, ignore_errors=True) + return "pasó" + +# Cada caso: qué se rompe, cómo, y qué tiene que pasar. +CASOS = [ + ("intacta (control: tiene que PASAR)", lambda p: None, "pasó"), + ("id empaquetado que la política no declara", + lambda p: p["policies"]["ExtensionSettings"].pop("proxy@atuq.tawasuyu"), "MURIÓ"), + ("install_url apuntando a otro sitio", + lambda p: p["policies"]["ExtensionSettings"]["proxy@atuq.tawasuyu"].__setitem__( + "install_url", "file:///usr/lib/atuq/otro.xpi"), "MURIÓ"), + ("política que declara un XPI que no existe", + lambda p: p["policies"]["ExtensionSettings"].__setitem__( + "nadie@atuq.tawasuyu", {"install_url": "file:///x.xpi"}), "MURIÓ"), + ("proxy para un contenedor que la política no crea", + lambda p: p["policies"]["3rdparty"]["Extensions"]["proxy@atuq.tawasuyu"]["proxies"] + .__setitem__("Fantasma", {"type": "direct"}), "MURIÓ"), + ("3rdparty para una extensión que no es de atuq", + lambda p: p["policies"]["3rdparty"]["Extensions"].__setitem__( + "ajena@example.com", {"proxies": {}}), "MURIÓ"), +] + +fallos = 0 +for nombre, mutar, esperado in CASOS: + obtenido = correr(mutar) + ok = obtenido == esperado + fallos += not ok + print(f" [{'ok' if ok else 'FALLA'}] {nombre}: esperaba {esperado}, dio {obtenido}") +print("guardianes:", "todos correctos" if not fallos else f"{fallos} MAL") +sys.exit(1 if fallos else 0)