feat: cero dependencias externas y caché de peticiones al nodo

Las librerías (React, Babel) venían de unpkg.com y las fuentes de Google.
Ninguno veía qué transacciones analizabas, pero ambos recibían tu IP y la hora
en cada apertura: sabían que usabas Txoko, cuándo y desde dónde — el metadato
que la propia herramienta enseña a proteger. Ahora se sirven desde el nodo,
con verificación por hash y versiones fijadas. El dashboard funciona sin
internet.

Además, caché con TTL y coalescencia en useApi: las transacciones confirmadas
son inmutables y se cachean toda la sesión, así que repetir un análisis ya
explorado no cuesta ninguna petición al nodo (medido).
This commit is contained in:
2026-07-27 13:22:08 +02:00
parent fd2e38fd45
commit 020f9318aa
5 changed files with 269 additions and 13 deletions
+32
View File
@@ -10,6 +10,38 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
- **MENOR** — características nuevas que no rompen lo anterior
- **PARCHE** — arreglos de errores
---
## [1.11.0] — 2026-07-27
### Añadido
- **Las librerías del frontend se sirven desde tu propio nodo.** React, ReactDOM
y Babel se cargaban desde unpkg.com, y las fuentes IBM Plex desde
fonts.googleapis.com. Ninguno de los dos veía qué transacciones analizabas —
pero ambos recibían tu IP pública, la hora y el referer en cada apertura del
dashboard: sabían que usabas Txoko, cuándo y desde dónde. Ese metadato es
justo lo que esta herramienta enseña a proteger. Ahora no sale ni una
petición fuera de tu red, verificable con
`grep -E '(src|href)="https?://' dashboard.html` (no devuelve nada).
- Consecuencia práctica: **el dashboard funciona sin conexión a internet**.
- Las librerías se descargan una vez durante la instalación y se **verifican
por hash** (SHA-256 publicados en SETUP.md).
- Versiones fijadas (`react@18.3.1`, `@babel/standalone@7.23.10`) en vez de
rangos: antes el CDN decidía qué versión te entregaba y podía cambiar sin
aviso.
- `instalar-fuentes.sh` automatiza la descarga de IBM Plex (licencia OFL) y
genera el CSS. Comprueba cada archivo y aborta si algo falla.
- **Caché de respuestas del nodo con TTL y coalescencia** en `useApi`. Las
pestañas se montan y desmontan al navegar, así que ir a otra pestaña y volver
repetía todas las peticiones. Ahora:
- Las **transacciones confirmadas se cachean toda la sesión** — son
inmutables, no hay motivo para volver a pedirlas. Analizador, rastro de
procedencia y peritaje dejan de pedir las mismas por separado.
- Historial de direcciones 60 s, bloques 60 s, mempool y comisiones 20 s,
estado del sistema 2 s (para que el monitor siga mostrando datos vivos).
- **Coalescencia:** dos componentes que piden lo mismo a la vez generan una
sola petición al nodo, no dos. Mismo patrón que ya usaba
`system-metrics.js` en el servidor.
- Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**.
---
## [1.10.1] — 2026-07-27
### Añadido
+4 -2
View File
@@ -39,9 +39,11 @@ El código es un único archivo HTML autocontenido. Puedes auditarlo tú mismo:
grep -E "fetch\(|XMLHttpRequest|src=\"http|href=\"http" dashboard.html
```
Verás que todas las llamadas van a rutas relativas (`/api/`, `/system/`) que apuntan a tu propio nodo vía nginx. No hay ninguna llamada a dominios externos en el código de análisis.
El comando anterior no debe devolver **ninguna** URL externa. Todas las llamadas van a rutas relativas (`/api/`, `/system/`, `/vendor/`) que apuntan a tu propio nodo vía nginx.
Las únicas dependencias externas son las librerías del frontend (React, Babel) que se cargan desde CDN al abrir la página. Estas no reciben ningún dato de tus transacciones — solo sirven el código de la interfaz.
Esto incluye las librerías del frontend (React y Babel): también se sirven desde tu nodo, no desde un CDN. Hasta la versión 1.10.1 se cargaban desde unpkg.com, y aunque un CDN nunca vio qué transacciones analizabas, sí recibía tu IP pública cada vez que abrías el dashboard — es decir, sabía que usabas Txoko, cuándo y desde dónde. Ese metadato es justo lo que esta herramienta enseña a proteger, así que se eliminó.
Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo necesita tu nodo. Las librerías se descargan una vez durante la instalación y se verifican por hash — ver [SETUP.md](SETUP.md).
---
+79
View File
@@ -159,6 +159,85 @@ Cuatro comandos después de la descarga. Siempre los mismos.
---
## Librerías del frontend (obligatorio)
El dashboard usa React y Babel. **Se sirven desde tu propio nodo, no desde un
CDN.** El motivo es de privacidad, no de comodidad: un CDN externo no ve qué
transacciones analizas, pero sí ve que usas Txoko, cuándo y desde qué IP
pública — exactamente la clase de metadato que esta herramienta enseña a
proteger. Sirviéndolas en local, *"ninguna consulta sale de tu red"* pasa a ser
literal, y el dashboard funciona sin conexión a internet.
Estas librerías **no están en el repo** (son de terceros y pesan ~3 MB). Se
descargan una vez, en el nodo:
```bash
sudo mkdir -p /usr/share/nginx/html/vendor
cd /usr/share/nginx/html/vendor
sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
```
### Verifica lo que has descargado
No te fíes: comprueba que los archivos son los que deben ser.
```bash
sha256sum *.js
```
Debe dar exactamente esto:
```
85ba0c7207cf1b1850e40372f26a7e69a481457a29649b7ca19bbfce8604f30c babel.min.js
35f4f974f4b2bcd44da73963347f8952e341f83909e4498227d4e26b98f66f0d react-dom.production.min.js
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
```
Si algún hash no coincide, **no uses esos archivos**: significa que has
recibido algo distinto a lo esperado.
Las versiones están fijadas a propósito (`18.3.1`, `7.23.10`). Usar un rango
como `react@18` dejaría que el servidor decidiera qué versión te entrega, y
cambiaría con el tiempo sin que te enteres.
### Dónde deben quedar los archivos
El dashboard las busca en `vendor/` con **ruta relativa**, es decir, en un
subdirectorio junto al propio `dashboard.html`. Así funciona tanto si sirves el
dashboard en la raíz como bajo un prefijo, sin tocar nginx.
Con la configuración típica de Mempool self-hosted:
```nginx
location /dashboard {
alias /usr/share/nginx/html/;
}
```
…el `dashboard.html` vive en `/usr/share/nginx/html/` y las librerías deben ir
en `/usr/share/nginx/html/vendor/` — que es justo donde las deja el comando de
arriba.
**Importante:** abre el dashboard **con la barra final** (`.../dashboard/`, no
`.../dashboard`). Sin ella, el navegador resuelve las rutas relativas un nivel
por encima y no encuentra las librerías.
Para comprobar que nginx las sirve bien, lo que importa no es solo el código
200 sino el tipo de contenido:
```bash
curl -sk -o /dev/null -w '%{http_code} %{content_type}\n' \
https://localhost:4081/dashboard/vendor/react.production.min.js
```
Debe responder `200 application/javascript`. Si devuelve `200 text/html`,
nginx está entregando otra cosa (por ejemplo el index de Mempool) y las rutas
no son las correctas.
---
## HTTPS para watch-only (opcional)
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
+71 -11
View File
@@ -4,9 +4,19 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Txoko Node Dashboard</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.23.10/babel.min.js"></script>
<!-- Librerías servidas desde TU nodo, no desde un CDN. Un CDN externo no ve
qué transacciones analizas, pero sí que usas Txoko, cuándo y desde qué
IP — justo la clase de metadato que esta herramienta enseña a proteger.
Sirviéndolas en local, "ninguna consulta sale de tu red" es literal, y
el dashboard funciona sin conexión a internet.
Cómo obtenerlas y verificarlas: ver SETUP.md. -->
<!-- Rutas RELATIVAS a propósito: el dashboard puede servirse en la raíz o
bajo un prefijo (con Mempool self-hosted suele ser /dashboard/), y así
funciona en ambos casos sin tocar nginx. Requiere abrirlo con la barra
final: .../dashboard/ y no .../dashboard -->
<script src="vendor/react.production.min.js"></script>
<script src="vendor/react-dom.production.min.js"></script>
<script src="vendor/babel.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #05080d; color: #dde6f0; font-family: 'IBM Plex Sans', 'Helvetica Neue', sans-serif; }
@@ -17,8 +27,10 @@
button:disabled { opacity:0.4; cursor:not-allowed !important; }
input::placeholder { color: #4a6380; }
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Fuentes servidas desde TU nodo. Antes venían de fonts.googleapis.com,
lo que le daba a Google tu IP y la hora en cada apertura del dashboard.
Instalación: instalar-fuentes.sh (ver SETUP.md). -->
<link href="vendor/fonts/ibm-plex.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
@@ -2054,13 +2066,63 @@
}
}
// ── Caché de respuestas del nodo ──────────────────────────────────────
// Vive FUERA del hook a propósito. Las pestañas se montan y desmontan al
// navegar ({tab==="node" && <NodeOverview/>}), así que sin esto ir a
// BLOQUES y volver a NODO vuelve a pedirlo todo aunque hayan pasado dos
// segundos. Mismo patrón que ya usa system-metrics.js en el servidor
// (caché con TTL + coalescencia), aplicado ahora también en el navegador.
//
// La coalescencia importa tanto como el TTL: dos componentes que piden lo
// mismo a la vez generan UNA petición al nodo, no dos.
const apiCache = new Map(); // clave -> { at, data, ttl }
const apiInflight = new Map(); // clave -> Promise en vuelo
const API_CACHE_MAX = 400; // tope de entradas, para no crecer sin fin
// Cuánto vale una respuesta depende de lo que sea. Una transacción ya
// confirmada es INMUTABLE: no hay ningún motivo para volver a pedirla en
// toda la sesión. Es el caso más frecuente en el analizador, el rastro de
// procedencia y el peritaje, que hoy piden las mismas txs por separado.
function apiCacheTtl(path, data) {
if (path.startsWith("/system/")) return 2000; // estado vivo del nodo: casi sin caché
if (/^\/api\/tx\/[0-9a-f]{64}$/i.test(path)) {
return data && data.status && data.status.confirmed ? Infinity : 15000;
}
if (path.startsWith("/api/address/")) return 60000; // historial: cambia poco
if (path.startsWith("/api/block")) return 60000;
return 20000; // mempool, fees y demás
}
async function apiGetJson(baseUrl, path) {
const key = baseUrl + path;
const hit = apiCache.get(key);
if (hit && (hit.ttl === Infinity || Date.now() - hit.at < hit.ttl)) return hit.data;
const flying = apiInflight.get(key);
if (flying) return flying; // ya hay una petición idéntica en curso
const p = (async () => {
const res = await fetchWithTimeout(key);
if (!res.ok) throw new Error(`el nodo respondió HTTP ${res.status}`);
const data = await res.json();
const ttl = apiCacheTtl(path, data);
if (ttl > 0) {
// FIFO simple: al llenarse, cae la entrada más antigua.
if (apiCache.size >= API_CACHE_MAX) apiCache.delete(apiCache.keys().next().value);
apiCache.set(key, { at: Date.now(), data, ttl });
}
return data;
})();
apiInflight.set(key, p);
try { return await p; } finally { apiInflight.delete(key); }
}
function useApi(baseUrl) {
const get = useCallback(async (path, mockData) => {
if (!baseUrl) return mockData;
try {
const res = await fetchWithTimeout(`${baseUrl}${path}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
return await apiGetJson(baseUrl, path);
} catch { return mockData; }
}, [baseUrl]);
// Variante estricta: propaga el fallo en vez de disfrazarlo de dato
@@ -2071,9 +2133,7 @@
// pericial falsa. Quien use getStrict debe capturar y decir qué pasó.
const getStrict = useCallback(async (path) => {
if (!baseUrl) throw new Error("no hay nodo configurado");
const res = await fetchWithTimeout(`${baseUrl}${path}`);
if (!res.ok) throw new Error(`el nodo respondió HTTP ${res.status}`);
return res.json();
return apiGetJson(baseUrl, path); // misma caché; los errores sí suben
}, [baseUrl]);
return { get, getStrict };
}
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Txoko — instalar IBM Plex en local (elimina la dependencia de Google Fonts)
#
# Descarga las fuentes IBM Plex (licencia OFL, libre) y genera el CSS que las
# declara. Tras ejecutarlo, el dashboard deja de contactar con
# fonts.googleapis.com y fonts.gstatic.com.
#
# Por qué importa: Google no ve qué transacciones analizas, pero sí recibe tu
# IP, la hora y la página desde la que se pide la fuente, cada vez que abres el
# dashboard. Para una herramienta de privacidad, sobra.
#
# Fuente: paquetes npm oficiales de IBM servidos por unpkg — el mismo origen
# del que ya se descargan React y Babel durante la instalación.
#
# Ejecutar EN EL NODO:
# chmod +x instalar-fuentes.sh && sudo ./instalar-fuentes.sh
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
DEST="${1:-/usr/share/nginx/html/vendor/fonts}"
MONO="https://unpkg.com/@ibm/plex-mono@1.1.0/fonts/complete/woff2"
SANS="https://unpkg.com/@ibm/plex-sans@1.1.0/fonts/complete/woff2"
echo "→ Instalando fuentes en: $DEST"
mkdir -p "$DEST"
cd "$DEST" || { echo "✗ No se pudo entrar en $DEST"; exit 1; }
# Pesos que usa el dashboard: Mono 400/600/700, Sans 400/500/600.
# Formato woff2 (el más comprimido, soportado por todo navegador actual).
URLS="
$MONO/IBMPlexMono-Regular.woff2
$MONO/IBMPlexMono-SemiBold.woff2
$MONO/IBMPlexMono-Bold.woff2
$SANS/IBMPlexSans-Regular.woff2
$SANS/IBMPlexSans-Medium.woff2
$SANS/IBMPlexSans-SemiBold.woff2
"
fallos=0
for url in $URLS; do
archivo=$(basename "$url")
printf " %-32s" "$archivo"
# -w escribe el código HTTP para poder diagnosticar si algo va mal
codigo=$(curl -sSL -o "$archivo" -w "%{http_code}" "$url" 2>/dev/null)
tam=$(stat -c%s "$archivo" 2>/dev/null || echo 0)
if [ "$codigo" = "200" ] && [ "$tam" -gt 10000 ]; then
echo "ok ($((tam/1024)) KB)"
else
echo "FALLÓ (HTTP $codigo, $tam bytes)"
fallos=$((fallos+1))
fi
done
if [ "$fallos" -gt 0 ]; then
echo
echo "$fallos archivo(s) no se descargaron correctamente."
echo " No despliegues el dashboard nuevo todavía — avísame con esta salida."
exit 1
fi
# ── CSS que declara las fuentes ──────────────────────────────────────────────
# font-display:swap → el texto se ve desde el primer momento con la fuente de
# respaldo y se sustituye al cargar la definitiva. Sin página en blanco.
cat > ibm-plex.css <<'CSS'
/* IBM Plex — servido desde tu propio nodo. Licencia OFL (IBM).
Sustituye a fonts.googleapis.com: ninguna petición sale de tu red. */
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexMono-Regular.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexMono-SemiBold.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:700;font-display:swap;src:url('IBMPlexMono-Bold.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexSans-Regular.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url('IBMPlexSans-Medium.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexSans-SemiBold.woff2') format('woff2')}
CSS
echo
echo "✓ Fuentes instaladas y CSS generado."
echo " Total: $(du -sh . | cut -f1)"
echo
echo " Comprueba que nginx las sirve (debe dar 200):"
echo " curl -sk -o /dev/null -w '%{http_code}\\n' https://localhost:4081/vendor/fonts/ibm-plex.css"
echo
echo " Si da 200, ya puedes desplegar el dashboard.html nuevo."