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:
@@ -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.
|
||||
|
||||
+30
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Executable
+69
@@ -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)
|
||||
Reference in New Issue
Block a user