atuq v0.5: proxy por contenedor — el primer diferenciador del §6 que se paga entero

Es el único de la lista del SDD 26 §6 que no pasa por el host de native
messaging del §7: es API de Firefox y nada más. Cada contenedor —Personal,
Trabajo, Banco, Compras— puede salir por su propio proxy.

Tres piezas y ninguna alcanza sola: `atuq.cfg` prende los contenedores (vienen
apagados), `policies.json` los CREA con `Containers.Default` —el único
mecanismo que los pone en un perfil NUEVO— y deja la config de fábrica en
`3rdparty.Extensions`, y `extensions/proxy/` los enruta con `proxy.onRequest`,
que es lo único que ve el `cookieStoreId` de la petición.

Las cuatro se comprobaron DENTRO del artefacto de firefox antes de escribir una
línea, no en la documentación de Mozilla: `Containers` y `3rdparty` en el
`policies-schema.json` de browser/omni.ja, `cookieStoreId` en el
`schemas/proxy.json` de omni.ja, y `storage.managed` leyendo
`getExtensionPolicy(id)` en ext-storage.js. Es la regla del §2.sexies: la
pregunta no es si Mozilla lo tiene, es si NUESTRO build lo tiene.

Tres decisiones que valen más que el código:

1. La config se indexa 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.
2. FAIL CLOSED. Un contenedor con proxy configurado que no se pudo honrar no
   sale directo: va a un destino cerrado y el navegador muestra el error. Salir
   directo sería una fuga silenciosa — la misma familia que el artefacto vacío
   de la regla 3, el fallo que llega hasta el final diciendo que todo fue bien.
3. `proxyDNS` PRENDIDO por defecto: sin él la consulta DNS sale por la línea que
   se quería evitar. Es la fuga clásica de esta configuración.

Y lo que no promete está arriba de todo en la página de opciones, no en un pie:
separación de tráfico, NO anonimato; para anonimato, Tor Browser. El §4 cumplido
donde el usuario lo lee.

De paso, una verdad que estaba escrita en dos sitios pasa a tener un dueño: el
id de cada extensión sale ahora del `manifest.json` y no de una constante de
rebrand.py, el nombre del XPI se deriva de él, y el `install_url` de la política
se cruza contra la ruta donde el fichero quedó escrito de verdad. El icono se
inyecta desde branding/icons/ en vez de estar copiado byte a byte dentro de cada
extensión. Agregar una tercera extensión es ahora un directorio.

PROBADO, corriendo el árbol en la misma jaula que atuq-nested.sh:
  · captura de about:preferences#containers con los cuatro contenedores y sus
    iconos, más el containers.json del perfil;
  · extensions.json del perfil nombra las dos extensiones;
  · `console.info: "atuq/proxy: 4 contenedor(es) enrutado(s)"` — leyó la config
    de fábrica por storage.managed Y la casó con los contenedores de la política;
  · scripts/test-atuq-politica.py: cinco formas de desincronizar política y XPI,
    las cinco matan el build, y el control con la política intacta pasa.

NO probado y dicho por su nombre: que una petición hecha en «Banco» salga por el
proxy de «Banco». Pide automatizar la UI y queda pendiente.

Dos obstáculos del método, que valen para la próxima. La consola de una
extensión es CONTENIDO: `devtools.console.stdout.chrome` (que viene en true) no
la incluye, hace falta `...stdout.content`. Y un `moz-extension://` NO se abre
desde la línea de comandos —muere con `NS_NOINTERFACE [nsIFileURL.file]` y abre
la home en su lugar—, además de que `--screenshot` dispara al `load`, que puede
ocurrir antes de que arranquen las extensiones. Eso último destapó un fallo real
y arreglado: la página de opciones confundía «no hay contenedores» con «la API
no está» y mostraba un mensaje FALSO.

Verificado contra firefox b3:352d7880; se reconstruye contra el firefox con
RLBox cuando selle. La otra mitad de esta unidad —el rename de
recipes/atuq/extension/ a extensions/inicio/— entró sin querer en ff0b556, que
es de otro frente.

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 01:57:44 +00:00
co-authored by Claude Opus 5
parent df4111c57d
commit c5ebbda933
11 changed files with 915 additions and 38 deletions
+71 -2
View File
@@ -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.56.9 | 5 |
| 9 | Torrent, medios, foco | 6.56.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.
+30 -1
View File
@@ -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; }
+52 -5
View File
@@ -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` | `<appdir>/atuq.cfg` | Prefs de fábrica + carga la hoja de estilo del chrome. |
| `chrome/atuq.css` | `<appdir>/chrome/atuq.css` | El aspecto. Se inyecta como AGENT_SHEET desde `atuq.cfg`. |
| `distribution/policies.json` | `<appdir>/distribution/policies.json` | Política de fábrica: telemetría, updates, **y la instalación de la extensión de inicio**. |
| `extension/` | `<appdir>/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. |
| `distribution/policies.json` | `<appdir>/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/` | `<appdir>/distribution/extensions/inicio@atuq.tawasuyu.xpi` | Página de inicio y pestaña nueva. |
| `extensions/proxy/` | `<appdir>/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/<lo-que-sea>/` 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
`<id>.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.
+19
View File
@@ -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);
+24
View File
@@ -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"
}
}
}
+208
View File
@@ -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: ["<all_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();
}
});
@@ -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",
"<all_urls>"
],
"background": {
"scripts": ["fondo.js"],
"persistent": true
},
"options_ui": {
"page": "opciones.html",
"open_in_tab": true
}
}
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>atuq — proxy por contenedor</title>
<style>
/* Misma paleta que `chrome/atuq.css` y que `inicio.html`, muestreada del medallón. Duplicada por
la misma razón que allá: cada página vive en su propio mundo y no comparten hoja. Está anotado
en el README para que no se desincronicen en silencio. */
:root {
--naranja: #c15728;
--teal: #1b6470;
--noche: #04161d;
--crema: #f5ecde;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.2rem 1.4rem 4rem;
background: var(--noche); color: var(--crema);
font: 15px/1.55 system-ui, sans-serif;
}
main { width: min(860px, 94vw); margin: 0 auto; }
h1 { margin: 0 0 .3rem; font-size: 1.7rem; font-weight: 600; }
h1 img { width: 34px; height: 34px; vertical-align: -8px; margin-right: .5rem; }
.sub { margin: 0 0 1.6rem; color: color-mix(in srgb, var(--crema) 62%, transparent); }
/* La advertencia NO es un pie de página: va arriba, antes de la primera perilla, porque una
promesa falsa de privacidad es peor que no ofrecer nada (SDD 26 §4). */
.aviso {
border: 1px solid color-mix(in srgb, var(--naranja) 55%, transparent);
background: color-mix(in srgb, var(--naranja) 12%, transparent);
border-radius: 10px; padding: .9rem 1.1rem; margin: 0 0 1.8rem;
}
.aviso b { color: var(--naranja); }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: .55rem .5rem; vertical-align: middle; }
th { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em;
color: color-mix(in srgb, var(--crema) 55%, transparent); font-weight: 600; }
tbody tr { border-top: 1px solid color-mix(in srgb, var(--crema) 14%, transparent); }
.nombre { display: flex; align-items: center; gap: .55rem; font-weight: 600; }
.punto { width: .7rem; height: .7rem; border-radius: 50%; flex: none; }
.fabrica { font-size: .72rem; font-weight: 400; padding: .1rem .45rem; border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--teal) 70%, var(--crema));
color: color-mix(in srgb, var(--crema) 80%, transparent); }
select, input {
font: inherit; color: var(--crema);
background: color-mix(in srgb, var(--crema) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--crema) 26%, transparent);
border-radius: 7px; padding: .38rem .55rem; outline: none;
}
select:focus, input:focus { border-color: var(--naranja); box-shadow: 0 0 0 3px color-mix(in srgb, var(--naranja) 26%, transparent); }
input.host { width: 13rem; }
input.port { width: 5.5rem; }
input:disabled, select:disabled { opacity: .38; }
td.dns { text-align: center; }
footer { margin-top: 1.8rem; display: flex; align-items: center; gap: 1rem; }
button {
font: inherit; font-weight: 600; color: var(--noche); background: var(--naranja);
border: 0; border-radius: 8px; padding: .55rem 1.3rem; cursor: pointer;
}
button.secundario { color: var(--crema); background: transparent;
border: 1px solid color-mix(in srgb, var(--crema) 30%, transparent); }
button:focus-visible { outline: 2px solid var(--crema); outline-offset: 2px; }
#estado { color: color-mix(in srgb, var(--crema) 70%, transparent); }
#vacio { padding: 1.4rem 0; color: color-mix(in srgb, var(--crema) 62%, transparent); }
code { background: color-mix(in srgb, var(--crema) 10%, transparent); padding: .1rem .3rem; border-radius: 4px; }
</style>
</head>
<body>
<main>
<h1><img src="atuq128.png" alt="">Proxy por contenedor</h1>
<p class="sub">Cada contenedor sale por su propia línea. Lo que hagas en uno no viaja por la del otro.</p>
<p class="aviso">
<b>Esto es separación de tráfico, no anonimato.</b> 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 <b>Tor Browser</b>, y
te lo decimos nosotros primero.
</p>
<table>
<thead>
<tr><th>Contenedor</th><th>Salida</th><th>Servidor</th><th>Puerto</th><th class="dns">DNS por el proxy</th></tr>
</thead>
<tbody id="filas"></tbody>
</table>
<p id="vacio" hidden>
No hay contenedores en este perfil. Se habilitan en <code>about:preferences#containers</code>;
atuq trae los suyos declarados en <code>distribution/policies.json</code>.
</p>
<footer>
<button id="guardar">Guardar</button>
<button id="restablecer" class="secundario">Volver a los de fábrica</button>
<span id="estado" role="status" aria-live="polite"></span>
</footer>
</main>
<script src="opciones.js"></script>
</body>
</html>
+186
View File
@@ -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();
+126 -30
View File
@@ -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()
+69
View File
@@ -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)