diff --git a/.env.example b/.env.example index d68b853a7..bce012123 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ RIPESTAT_SOURCE_APP="" SECURITY_BLACKLIST_LOG_FILE_PATH="" SECURITY_RATE_LIMIT="" SECURITY_DELAY_AFTER="" +# SITE +VITE_SITE_URL="https://ipcheck.ing" # CURL API VITE_CURL_IPV4_DOMAIN="" VITE_CURL_IPV6_DOMAIN="" @@ -27,6 +29,8 @@ VITE_GOOGLE_ANALYTICS_ID="" MAXMIND_ACCOUNT_ID="" MAXMIND_LICENSE_KEY="" MAXMIND_AUTO_UPDATE="false" +# CAIDA +CAIDA_AUTO_UPDATE="false" # Logging — LOG_LEVEL: debug/info/warn/error (default info) # LOG_FORMAT: "json" for log shippers, anything else = pretty # LOG_HTTP: "true" to enable per-request /api logging (off by default) diff --git a/.gitignore b/.gitignore index fb6d570fb..1d4a1fbb1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,31 @@ common/maxmind-db/.maxmind-update.lock common/maxmind-db/*.bak common/maxmind-db/*.next common/maxmind-db/*.mmdb + +# CAIDA datasets — Non-Commercial license, don't commit to public repo. +common/as-org-db/*.txt +common/as-rel-db/*.txt +common/as-org-db/.caida-update-state.json +common/as-rel-db/.caida-update-state.json +common/as-org-db/.caida-update.lock +common/as-rel-db/.caida-update.lock +common/as-org-db/*.bak +common/as-rel-db/*.bak +common/as-org-db/*.next +common/as-rel-db/*.next .learnings/ docs/ +# Local Scripts +scripts/ + +# Site configuration +public/sitemap.xml +public/robots.txt +public/llms.txt +public/llms-full.txt +public/tools/ +public/robots.txt + # Private AI context (per-machine, not for the public repo) local-context.md diff --git a/README.md b/README.md index 1033b7146..1a89778ff 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Feel free to bookmark the demo or deploy your own. * 🛜 **View Your IPs**: Detects and displays your local IPs, sourcing from multiple IPv4 and IPv6 providers. * 🔍 **Search IP Information**: Provides a tool for querying information about any IP address. * 🕵️ **IP Information**: Presents detailed information for all IP addresses, including country, region, ASN, geographic location, and more. +* 🛰️ **ASN History & Upstream Topology**: View historical AS announcements for an IP prefix, and visualize the upstream paths from an ASN to the Tier 1 backbone networks. * 🚦 **Availability Check**: Tests the accessibility of various websites, such as Google, GitHub, YouTube, ChatGPT, and others. * 🚥 **WebRTC Detection**: Identifies the IP address used during WebRTC connections. * 🛑 **DNS Leak Test**: Shows DNS endpoint data to evaluate the risk of DNS leaks when using VPNs or proxies. @@ -50,7 +51,6 @@ Feel free to bookmark the demo or deploy your own. ## 💪 Also * 🌗 **Dark Mode**: Automatically toggles between dark and daylight modes based on system settings, with an option for manual switching. -* 📱 **Minimalist Mode**: A mobile-optimized mode that shortens page length for quick access to essential information.. * 📲 **PWA Supported**:Can be added as a desktop app on your phone as well as a Chrome app on your computer. * ⌨️ **Keyboard Shortcuts**: Supports keyboard shortcuts for all functions, press `?` to view the shortcut list. * 🌍 Based on availability test results, it indicates whether global internet access is currently feasible. @@ -125,6 +125,7 @@ Download `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` from your MaxMind account | `MAXMIND_ACCOUNT_ID` | **Yes** | `""` | MaxMind account ID, paired with `MAXMIND_LICENSE_KEY` to download GeoLite2 databases. See the MaxMind section above. | | `MAXMIND_LICENSE_KEY` | **Yes** | `""` | MaxMind license key, paired with `MAXMIND_ACCOUNT_ID`. See the MaxMind section above. | | `MAXMIND_AUTO_UPDATE` | **Yes** | `"false"` | Set to `"true"` to auto-download GeoLite2 databases ~60s after startup and refresh every 24h. **Required for Docker.** Can stay `"false"` only if you've pre-seeded the `.mmdb` files manually. | +| `CAIDA_AUTO_UPDATE` | No | `"false"` | Set to `"true"` to refresh the CAIDA datasets daily (as2org for ASN org-name lookup, as-rel2 for the ASN connectivity graph). When `"false"`, missing snapshots are still downloaded at startup but never refreshed afterwards. | | `VITE_GOOGLE_ANALYTICS_ID` | **Yes** | `""` | Google Analytics ID, used to track user behavior | | `BACKEND_PORT` | No | `"11966"` | The running port of the backend part of the program | | `FRONTEND_PORT` | No | `"18966"` | The running port of the frontend part of the program | diff --git a/README_FR.md b/README_FR.md index 5a6d75b16..2bf6a78a9 100644 --- a/README_FR.md +++ b/README_FR.md @@ -33,6 +33,7 @@ Notes: Vous pouvez utiliser ma démo gratuitement et vous pouvez également la d * 🛜 **Afficher vos adresses IP** : Détecte et affiche votre adresse IP locale, provenant de plusieurs fournisseurs IPv4 et IPv6. * 🔍 **Recherche d'informations sur l'adresse IP** : Fournit un outil pour interroger des informations sur n'importe quelle adresse IP. * 🕵️ **Informations sur l'adresse IP** : Présente des informations détaillées pour toutes les adresses IP, y compris le pays, la région, l'ASN, la localisation géographique, et plus encore. +* 🛰️ **Historique ASN et topologie amont** : Consultez l'historique des annonces AS pour un préfixe IP, et visualisez les chemins amont d'un ASN vers les réseaux dorsaux Tier 1. * 🚦 **Vérification de disponibilité** : Teste l'accessibilité de différents sites web, tels que Google, GitHub, YouTube, ChatGPT, et d'autres. * 🚥 **Détection WebRTC** : Identifie l'adresse IP utilisée lors des connexions WebRTC. * 🛑 **Test de fuite DNS** : Affiche les données de point de terminaison DNS pour évaluer le risque de fuites DNS lors de l'utilisation de VPN ou de proxies. @@ -50,7 +51,6 @@ Notes: Vous pouvez utiliser ma démo gratuitement et vous pouvez également la d ## 💪Également * 🌗 **Mode sombre** : Bascule automatiquement entre les modes sombre et clair en fonction des paramètres du système, avec une option de basculement manuel. -* 📱 **Mode minimaliste** : Un mode optimisé pour les mobiles qui réduit la longueur de la page pour un accès rapide aux informations essentielles. * 📲 **Prise en charge de PWA** : Peut être ajouté en tant qu'application de bureau sur votre téléphone ainsi qu'en tant qu'application Chrome sur votre ordinateur. * ⌨️ **Raccourcis clavier** : Prend en charge les raccourcis clavier pour toutes les fonctions, appuyez sur `?` pour afficher la liste des raccourcis. * 🌍 Basé sur les résultats des tests de disponibilité, il indique si l'accès Internet mondial est actuellement réalisable. @@ -125,6 +125,7 @@ Téléchargez `GeoLite2-City.mmdb` et `GeoLite2-ASN.mmdb` depuis votre compte Ma | `MAXMIND_ACCOUNT_ID` | **Oui** | `""` | ID de compte MaxMind, associé à `MAXMIND_LICENSE_KEY` pour télécharger les bases GeoLite2. Voir la section MaxMind ci-dessus. | | `MAXMIND_LICENSE_KEY` | **Oui** | `""` | Clé de licence MaxMind, associée à `MAXMIND_ACCOUNT_ID`. Voir la section MaxMind ci-dessus. | | `MAXMIND_AUTO_UPDATE` | **Oui** | `"false"` | Définissez sur `"true"` pour télécharger automatiquement les bases GeoLite2 environ 60 s après le démarrage et les rafraîchir toutes les 24 h. **Obligatoire pour Docker.** Peut rester à `"false"` uniquement si vous avez pré-déposé les fichiers `.mmdb` manuellement. | +| `CAIDA_AUTO_UPDATE` | Non | `"false"` | Définissez sur `"true"` pour rafraîchir quotidiennement les jeux de données CAIDA (as2org pour la résolution du nom d'organisation par ASN, as-rel2 pour le graphe de connectivité ASN). Lorsque `"false"`, les snapshots manquants sont quand même téléchargés au démarrage mais ne sont jamais rafraîchis ensuite. | | `VITE_GOOGLE_ANALYTICS_ID` | **Oui** | `""` | Identifiant Google Analytics, utilisé pour l'analyse des utilisateurs | | `BACKEND_PORT` | Non | `"11966"` | Le port d'exécution de la partie backend du programme | | `FRONTEND_PORT` | Non | `"18966"` | Le port d'exécution de la partie frontend du programme | diff --git a/README_TR.md b/README_TR.md index dcdd4ad40..6d9bacef3 100644 --- a/README_TR.md +++ b/README_TR.md @@ -33,6 +33,7 @@ Demo'yu yer imlerine ekleyebilir veya kendi kurulumunuzu yapabilirsiniz. * 🛜 **IP'lerinizi Görüntüleyin**: Yerel IP'lerinizi tespit eder ve birden fazla IPv4/IPv6 sağlayıcısından alır. * 🔍 **IP Bilgisi Arama**: Herhangi bir IP adresi hakkında sorgu yapma aracı sağlar. * 🕵️ **IP Bilgileri**: Ülke, bölge, ASN, coğrafi konum ve daha fazlasını içeren ayrıntılı IP bilgileri sunar. +* 🛰️ **ASN Geçmişi ve Üst Topoloji**: Bir IP önekinin geçmiş AS duyurularını ve ASN'den Tier 1 omurga ağlarına giden üst bağlantı yollarını görüntüleyin. * 🚦 **Erişilebilirlik Kontrolü**: Google, GitHub, YouTube, ChatGPT ve diğerleri gibi sitelerin erişilebilirliğini test eder. * 🚥 **WebRTC Tespiti**: WebRTC bağlantısında kullanılan IP adresini belirler. * 🛑 **DNS Leak Testi**: VPN veya proxy kullanırken DNS sızıntısı riskini değerlendirmek için DNS uç nokta verilerini gösterir. @@ -50,7 +51,6 @@ Demo'yu yer imlerine ekleyebilir veya kendi kurulumunuzu yapabilirsiniz. ## 💪 Ayrıca * 🌗 **Karanlık Mod**: Sistem ayarlarına göre otomatik olarak gündüz/karanlık mod arasında geçiş yapar; manuel geçiş seçeneği de vardır. -* 📱 **Minimal Mod**: Mobil için optimize edilmiş, sayfa uzunluğunu kısaltan hızlı erişim modu. * 📲 **PWA Desteği**:Telefonunuza masaüstü uygulaması olarak veya bilgisayarınızda Chrome uygulaması olarak eklenebilir. * ⌨️ **Klavye Kısayolları**: Tüm işlevler için kısayolları destekler; kısayol listesini görmek için `?` tuşuna basın. * 🌍 Erişilebilirlik test sonuçlarına göre küresel internet erişiminin şu an mümkün olup olmadığını gösterir. @@ -125,6 +125,7 @@ MaxMind hesabınızdan `GeoLite2-City.mmdb` ve `GeoLite2-ASN.mmdb` dosyalarını | `MAXMIND_ACCOUNT_ID` | **Evet** | `""` | MaxMind hesap ID'si, GeoLite2 veritabanlarını indirmek için `MAXMIND_LICENSE_KEY` ile birlikte kullanılır. Yukarıdaki MaxMind bölümüne bakın. | | `MAXMIND_LICENSE_KEY` | **Evet** | `""` | MaxMind lisans anahtarı, `MAXMIND_ACCOUNT_ID` ile birlikte kullanılır. Yukarıdaki MaxMind bölümüne bakın. | | `MAXMIND_AUTO_UPDATE` | **Evet** | `"false"` | `"true"` yapıldığında GeoLite2 veritabanları başlatmadan yaklaşık 60 saniye sonra otomatik olarak indirilir ve her 24 saatte bir yenilenir. **Docker için zorunlu.** Yalnızca `.mmdb` dosyalarını manuel olarak yerleştirdiyseniz `"false"` olarak kalabilir. | +| `CAIDA_AUTO_UPDATE` | Hayır | `"false"` | `"true"` yapıldığında CAIDA veri setleri günlük olarak yenilenir (as2org ASN org adı için, as-rel2 ASN bağlantı haritası için). `"false"` olduğunda, eksik anlık görüntüler başlatmada yine de indirilir ancak sonradan yenilenmez. | | `VITE_GOOGLE_ANALYTICS_ID` | **Evet** | `""` | Google Analytics ID, kullanıcı davranışını izlemek için | | `BACKEND_PORT` | Hayır | `"11966"` | Backend kısmının çalıştığı port | | `FRONTEND_PORT` | Hayır | `"18966"` | Frontend kısmının çalıştığı port | diff --git a/README_ZH.md b/README_ZH.md index 63dddb62f..ce85448d1 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -33,6 +33,7 @@ * 🛜 **看自己的 IP**:从多个 IPv4 和 IPv6 来源检测显示本机的 IP * 🔍 **查任意 IP 信息**:可以通过小工具查询任意 IP 的信息 * 🕵️ **看 IP 信息**:显示所有 IP 的相关信息,包括国家、地区、ASN、地理位置等 +* 🛰️ **ASN 历史与上游拓扑**:查看 IP 前缀的历史 AS 宣告记录,以及该 ASN 到 Tier 1 骨干网的上游路径图 * 🚦 **可用性检测**:检测一些网站的可用性:Google, Github, Youtube, 网易, 百度等 * 🚥 **WebRTC 检测**:查看使用 WebRTC 连接时使用的 IP * 🛑 **DNS 泄露检测**:查看 DNS 出口信息,以便查看在 VPN/代理的情况下,是否存在 DNS 泄露隐私的风险 @@ -50,7 +51,6 @@ ## 💪 同时还支持 * 🌗 **暗黑模式**:根据系统设置自动切换暗黑/白天模式,也可以手动切换 -* 📱 **简约模式**:为移动版提供的专门模式,缩短页面长度,快速查看最重要的信息 * 📲 **支持 PWA**:可以添加为手机应用以及电脑里的桌面应用,方便使用 * ⌨️ **支持快捷键**:可以随时输入 `?` 查看快捷键菜单 * 🌍 根据可用性检测结果,返回目前是否可以访问全世界网络的提示 @@ -125,6 +125,7 @@ MyIP 依赖 MaxMind 提供的免费 **GeoLite2** 数据库(City + ASN)来进 | `MAXMIND_ACCOUNT_ID` | **是** | `""` | MaxMind 账号 ID,和 `MAXMIND_LICENSE_KEY` 一起用于下载 GeoLite2 数据库。详见上方 MaxMind 配置说明。 | | `MAXMIND_LICENSE_KEY` | **是** | `""` | MaxMind License Key,和 `MAXMIND_ACCOUNT_ID` 配合使用。详见上方 MaxMind 配置说明。 | | `MAXMIND_AUTO_UPDATE` | **是** | `"false"` | 设置为 `"true"` 时,程序会在启动后 60 秒左右自动下载 GeoLite2 数据库,之后每 24 小时刷新一次。**Docker 部署必须设置为 `"true"`。** 只有当你已经手动放置了 `.mmdb` 文件时,才能保持为 `"false"`。 | +| `CAIDA_AUTO_UPDATE` | 否 | `"false"` | 设置为 `"true"` 时,每天自动刷新 CAIDA 数据集(as2org 用于 ASN 组织名查询、as-rel2 用于 ASN 连接图)。设置为 `"false"` 时仍会在启动时下载缺失的快照,之后保持不变。 | | `VITE_GOOGLE_ANALYTICS_ID` | **是** | `""` | Google Analytics 的 ID,用于统计访问量 | | `BACKEND_PORT` | 否 | `"11966"` | 程序后端部分的运行端口 | | `FRONTEND_PORT` | 否 | `"18966"` | 程序前端部分的运行端口 | diff --git a/api/AGENTS.md b/api/AGENTS.md index aeda073f7..768de800f 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -69,6 +69,7 @@ common/ - `requireReferer` is mounted globally on `/api/*` in `backend-server.js`. It rejects any request whose `Referer` header isn't on the `ALLOWED_DOMAINS` list (plus `localhost` always). **Handlers must not repeat the referer check.** - `requireValidIP()` is attached per-route to every handler that takes `?ip=`. It rejects missing or malformed IPs before the handler runs. **Handlers must not repeat the IP check** — inside the handler body, `req.query.ip` is already known to be a well-formed string. - `requireValidPrefix()` is the same pattern for `?prefix=` (CIDR-shaped param). Used by `asn-history` so the frontend can quantize the user's IP to its BGP DFZ-floor (/24 v4 or /48 v6) before the request lands, maximizing CF edge cache reuse across every IP in the same prefix. +- `requireValidASN()` does the same for `?asn=` (numeric, with optional leading `AS`). Strips the prefix and rewrites `req.query.asn` to a pure numeric string. Used by `asn-connectivity`; older handlers (`cf-radar`) still validate inline. - If you add a new handler that needs a different-shape param guard, add the guard to `common/guards.js` and attach it in `backend-server.js` rather than open-coding the check in the handler. ### Private-API header pass-through (intentional exception) diff --git a/api/asn-connectivity.js b/api/asn-connectivity.js new file mode 100644 index 000000000..7cbd10068 --- /dev/null +++ b/api/asn-connectivity.js @@ -0,0 +1,145 @@ +// /api/asn-connectivity — BFS over CAIDA AS-Relationships starting at the +// origin AS, halting at Tier 1s or MAX_DEPTH. Returns a graph the frontend +// renders dagre-style: origin (left) → intermediates → Tier 1s (right). +// Inspired by bgp.tools' /as/#connectivity view. +// +// Data is fully local (common/as-rel-db.js + common/as-org-db.js), so the +// whole BFS is synchronous. We only hit RIPEstat for as-overview as a +// rare fallback when as2org doesn't have an ASN's org name. + +import { fetchAsOverview, extractOrgFromHolder } from '../common/ripestat.js'; +import { lookupAsOrgName } from '../common/as-org-db.js'; +import { providersOf, customerCountOf, isTier1 } from '../common/as-rel-db.js'; +import logger from '../common/logger.js'; + +// How deep to recurse from the origin. 3 covers regional networks reaching +// Tier 1s through 1-2 intermediates; deeper just adds noise at the periphery. +const MAX_DEPTH = 3; + +// Per-node cap on non-Tier-1 providers to recurse into. Matters mainly for +// hyperscalers (Cloudflare-class has 100+ providers); ranked by customerCount +// as a proxy for "primary transit". +const MAX_INTERMEDIATE_BRANCH = 3; + +// Two-tier org name resolver: local CAIDA as2org first (µs), RIPEstat +// as-overview fallback when the snapshot doesn't have the ASN. +async function resolveOrgName(asn) { + const local = lookupAsOrgName(asn); + if (local) return local; + try { + const res = await fetchAsOverview(asn); + if (!res.ok) return null; + const payload = await res.json(); + return extractOrgFromHolder(payload?.data?.holder); + } catch { + return null; + } +} + +async function buildGraph(origin) { + const nodes = new Map(); + const edgeSet = new Set(); + const orgPromises = new Map(); + + // If the queried AS is itself a Tier 1, mark it 'origin-tier1' so the + // frontend can render the combined origin+Tier1 styling; the rest of + // the BFS is a no-op (Tier 1s have no providers) and the graph is + // legitimately a single node. + const originType = isTier1(origin) ? 'origin-tier1' : 'origin'; + nodes.set(origin, { asn: origin, type: originType, name: null }); + orgPromises.set(origin, resolveOrgName(origin)); + + let currentLayer = [origin]; + + for (let depth = 0; depth < MAX_DEPTH; depth++) { + if (currentLayer.length === 0) break; + const nextLayer = []; + + for (const asn of currentLayer) { + const providers = providersOf(asn); + if (providers.length === 0) continue; + + // Tier 1 hits are terminal — record the edge + node, no recursion. + for (const p of providers) { + if (!isTier1(p)) continue; + edgeSet.add(`${asn}->${p}`); + if (!nodes.has(p)) { + nodes.set(p, { asn: p, type: 'tier1', name: null }); + orgPromises.set(p, resolveOrgName(p)); + } + } + + // Non-Tier-1 providers: pick top-N by customerCount so when we + // truncate, the displayed ones are the more meaningful transits. + const intermediates = providers + .filter(p => !isTier1(p)) + .sort((a, b) => customerCountOf(b) - customerCountOf(a)) + .slice(0, MAX_INTERMEDIATE_BRANCH); + + for (const p of intermediates) { + edgeSet.add(`${asn}->${p}`); + if (!nodes.has(p)) { + nodes.set(p, { asn: p, type: 'intermediate', name: null }); + orgPromises.set(p, resolveOrgName(p)); + nextLayer.push(p); + } + } + } + + currentLayer = nextLayer; + } + + // Await all org lookups. They've been running in the background since + // each node was discovered, so most are already settled. + for (const [asn, promise] of orgPromises) { + try { + const name = await promise; + const node = nodes.get(asn); + if (node && name) node.name = name; + } catch { + // node keeps name=null + } + } + + const allNodes = [...nodes.values()]; + const allEdges = [...edgeSet].map(s => { + const [from, to] = s.split('->').map(Number); + return { from, to }; + }); + return pruneLeafIntermediates(allNodes, allEdges); +} + +// Iteratively drop intermediate nodes with no outgoing edge — visual +// dead-ends that contribute no info. Iterates to fixed-point because +// removing one leaf can turn its parent into a leaf. origin / origin-tier1 +// / tier1 are never pruned. +function pruneLeafIntermediates(nodes, edges) { + let currentNodes = nodes; + let currentEdges = edges; + while (true) { + const hasOutgoing = new Set(currentEdges.map(e => e.from)); + const survivors = currentNodes.filter(n => + n.type !== 'intermediate' || hasOutgoing.has(n.asn) + ); + if (survivors.length === currentNodes.length) { + return { nodes: currentNodes, edges: currentEdges }; + } + const survivorAsns = new Set(survivors.map(n => n.asn)); + currentEdges = currentEdges.filter(e => + survivorAsns.has(e.from) && survivorAsns.has(e.to) + ); + currentNodes = survivors; + } +} + +export default async (req, res) => { + // ASN presence + numeric validity guaranteed by requireValidASN middleware. + const asn = parseInt(req.query.asn, 10); + try { + const graph = await buildGraph(asn); + res.json({ origin: asn, ...graph }); + } catch (error) { + logger.error({ err: error, asn }, 'asn-connectivity handler failed'); + res.status(500).json({ error: error.message }); + } +}; diff --git a/api/asn-history.js b/api/asn-history.js index ab1400f82..f1d5da344 100644 --- a/api/asn-history.js +++ b/api/asn-history.js @@ -2,8 +2,20 @@ // frontend quantizes the user's IP to /24 v4 or /48 v6 first, so all IPs // in the same prefix collapse to one CF edge cache entry). Org names per // ASN come from RIPEstat as-overview, fetched in parallel, best-effort. - -import { fetchUpstream } from '../common/fetch-with-timeout.js'; +// +// `peers` is the peak RIS-peer count for a row (raw RIPE observation, not +// global BGP reach). We also emit `peersPct` — each row's peers normalized +// by the response's max — so the UI can show a relative visibility metric +// instead of an absolute count that users might mistake for "the whole +// internet". The max-of-response baseline is a fair proxy for "active RIS +// peers" since well-propagated announcements typically saturate the panel. + +import { + fetchRoutingHistory, + fetchAsOverview, + extractOrgFromHolder, +} from '../common/ripestat.js'; +import { lookupAsOrgName } from '../common/as-org-db.js'; import logger from '../common/logger.js'; const prefixLength = (prefix) => parseInt((prefix || '').split('/')[1], 10); @@ -15,14 +27,6 @@ const MIN_PREFIX = { v4: 8, v6: 19 }; // Below this peer count an announcement is route noise / brief misconfig. const MIN_PEERS = 30; -// RIPEstat polite-citizen marker; overridable per deployment. -const SOURCE_APP = process.env.RIPESTAT_SOURCE_APP || 'myip'; - -// Shorter timeout for the org enrichment calls — they're best-effort, we'd -// rather return ASN-only history than make the user wait the full upstream -// timeout for a slow secondary lookup. -const ORG_FETCH_TIMEOUT_MS = 8000; - function summarizeOrigin(entry, minLen) { const acceptedPrefixes = (entry.prefixes || []).filter(p => prefixLength(p.prefix) >= minLen); if (acceptedPrefixes.length === 0) return null; @@ -54,20 +58,14 @@ function summarizeOrigin(entry, minLen) { }; } -// Holder is typically " - , "; strip the leading handle. -function extractOrgFromHolder(holder) { - if (!holder || typeof holder !== 'string') return null; - const dashIdx = holder.indexOf(' - '); - return dashIdx > 0 ? holder.slice(dashIdx + 3).trim() : holder.trim(); -} - -// Best-effort. Any failure (timeout, non-2xx, parse error) yields null so the -// parent Promise.all never rejects and the row falls back to ASN-only display. -async function fetchAsOrgName(asn) { +// Two-tier resolver: local CAIDA as2org first (µs), RIPEstat as-overview +// fallback. Best-effort — any failure yields null so the row drops to +// ASN-only display rather than blocking the whole batch. +async function resolveOrgName(asn) { + const local = lookupAsOrgName(asn); + if (local) return local; try { - const url = `https://stat.ripe.net/data/as-overview/data.json` - + `?resource=AS${encodeURIComponent(asn)}&sourceapp=${SOURCE_APP}`; - const res = await fetchUpstream(url, { timeoutMs: ORG_FETCH_TIMEOUT_MS }); + const res = await fetchAsOverview(asn); if (!res.ok) return null; const payload = await res.json(); return extractOrgFromHolder(payload?.data?.holder); @@ -79,16 +77,12 @@ async function fetchAsOrgName(asn) { export default async (req, res) => { // Prefix presence + validity guaranteed by requireValidPrefix middleware. - // Frontend quantizes the user's IP to /24 (v4) or /48 (v6) so every IP in - // the same prefix collapses to one cache entry at CF's edge. const prefix = req.query.prefix; const family = prefix.includes(':') ? 'v6' : 'v4'; const minLen = MIN_PREFIX[family]; try { - const url = `https://stat.ripe.net/data/routing-history/data.json` - + `?resource=${encodeURIComponent(prefix)}&sourceapp=${SOURCE_APP}`; - const apiRes = await fetchUpstream(url); + const apiRes = await fetchRoutingHistory(prefix); if (!apiRes.ok) { logger.warn({ prefix, status: apiRes.status }, 'RIPEstat routing-history non-2xx'); return res.status(502).json({ error: 'Upstream error' }); @@ -101,12 +95,19 @@ export default async (req, res) => { .filter(Boolean) .sort((a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || '')); + // Relative visibility: each row's peers / max peers in this response. + // Most-propagated row is 100%; sparse ones surface as low percentages. + const peersMax = history.reduce((m, r) => Math.max(m, r.peers), 0); + for (const row of history) { + row.peersPct = peersMax > 0 ? Math.round((row.peers / peersMax) * 100) : 0; + } + // Org enrichment is strictly best-effort: anything that goes wrong here // leaves rows with org=null, but the ASN-keyed timeline still ships. try { const uniqueAsns = [...new Set(history.map(row => row.asn))]; const orgPairs = await Promise.all( - uniqueAsns.map(async asn => [asn, await fetchAsOrgName(asn)]) + uniqueAsns.map(async asn => [asn, await resolveOrgName(asn)]) ); const orgByAsn = Object.fromEntries(orgPairs); for (const row of history) { @@ -118,6 +119,12 @@ export default async (req, res) => { res.json({ prefix, history }); } catch (error) { + // RIPEstat routing-history can exceed our timeout for prefixes with long + // history — surface that as 504 so it's distinguishable from real 5xx. + if (error?.name === 'AbortError') { + logger.warn({ prefix }, 'asn-history upstream timeout'); + return res.status(504).json({ error: 'Upstream timeout' }); + } logger.error({ err: error, prefix }, 'asn-history handler failed'); res.status(500).json({ error: error.message }); } diff --git a/api/cf-radar.js b/api/cf-radar.js index b8d88a51c..bf2ea9d99 100644 --- a/api/cf-radar.js +++ b/api/cf-radar.js @@ -86,21 +86,25 @@ function isValidASN(asn) { }; // Clean up Cloudflare Radar return data to uniform field names. -// Hoisted to module scope — was redefined inside the handler on every request. +// Optional-chaining everywhere because CF Radar returns sparse data for +// small / private / new ASNs (e.g. AS64512 is in the RFC 6996 private range +// and has no info at all; many smaller ASNs have asn info but no traffic +// summaries). Missing fields fall through as undefined and get stripped +// downstream in filterData via the NaN check. function cleanUpResponseData(data) { return { - asnName: data.asnInfo.result.asn.name, - asnCountryCode: data.asnInfo.result.asn.country, - asnOrgName: data.asnInfo.result.asn.orgName, - estimatedUsers: data.asnInfo.result.asn.estimatedUsers.estimatedUsers, - IPv4_Pct: data.ipVersion.result.summary_0.IPv4, - IPv6_Pct: data.ipVersion.result.summary_0.IPv6, - HTTP_Pct: data.httpProtocol.result.summary_0.http, - HTTPS_Pct: data.httpProtocol.result.summary_0.https, - Desktop_Pct: data.deviceType.result.summary_0.desktop, - Mobile_Pct: data.deviceType.result.summary_0.mobile, - Bot_Pct: data.botType.result.summary_0.bot, - Human_Pct: data.botType.result.summary_0.human + asnName: data.asnInfo?.result?.asn?.name, + asnCountryCode: data.asnInfo?.result?.asn?.country, + asnOrgName: data.asnInfo?.result?.asn?.orgName, + estimatedUsers: data.asnInfo?.result?.asn?.estimatedUsers?.estimatedUsers, + IPv4_Pct: data.ipVersion?.result?.summary_0?.IPv4, + IPv6_Pct: data.ipVersion?.result?.summary_0?.IPv6, + HTTP_Pct: data.httpProtocol?.result?.summary_0?.http, + HTTPS_Pct: data.httpProtocol?.result?.summary_0?.https, + Desktop_Pct: data.deviceType?.result?.summary_0?.desktop, + Mobile_Pct: data.deviceType?.result?.summary_0?.mobile, + Bot_Pct: data.botType?.result?.summary_0?.bot, + Human_Pct: data.botType?.result?.summary_0?.human }; } diff --git a/backend-server.js b/backend-server.js index cf330a84e..8f99289b4 100644 --- a/backend-server.js +++ b/backend-server.js @@ -7,7 +7,7 @@ import { slowDown } from 'express-slow-down' import rateLimit from 'express-rate-limit'; import pinoHttp from 'pino-http'; import logger from './common/logger.js'; -import { requireReferer, requireValidIP, requireValidPrefix } from './common/guards.js'; +import { requireReferer, requireValidIP, requireValidPrefix, requireValidASN } from './common/guards.js'; // Backend APIs import mapHandler from './api/google-map.js'; @@ -22,6 +22,7 @@ import maxmindHandler from './api/maxmind.js'; // Others import cfHander from './api/cf-radar.js'; import asnHistoryHandler from './api/asn-history.js'; +import asnConnectivityHandler from './api/asn-connectivity.js'; import dnsResolver from './api/dns-resolver.js'; import { getSessionResult as dnsLeakGetResult } from './api/dns-leak-test.js'; import getWhois from './api/get-whois.js'; @@ -33,6 +34,7 @@ import getUserinfo from './api/get-user-info.js'; import updateUserAchievement from './api/update-user-achievement.js'; import { reloadMaxMindDatabases, startMaxMindFileWatcher } from './common/maxmind-service.js'; import { startMaxMindAutoUpdate, bootstrapMaxMindIfMissing } from './common/maxmind-updater.js'; +import { startCaidaAutoUpdate, bootstrapCaidaIfMissing } from './common/caida-updater.js'; dotenv.config({ quiet: true }); @@ -188,14 +190,16 @@ app.use('/api', requireReferer); const ONE_HOUR_CACHE = 60 * 60; const ONE_DAY_CACHE = 24 * 60 * 60; +const ONE_WEEK_CACHE = 7 * 24 * 60 * 60; const THIRTY_DAYS_CACHE = 30 * 24 * 60 * 60; // Cacheable routes — TTLs picked against each upstream's natural refresh cadence. app.get('/api/ipinfo', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipinfoHandler); app.get('/api/ipapicom', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipapicomHandler); app.get('/api/ipsb', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipsbHandler); -app.get('/api/cfradar', cacheable(ONE_DAY_CACHE), cfHander); -app.get('/api/asn-history', requireValidPrefix(), cacheable(ONE_DAY_CACHE), asnHistoryHandler); +app.get('/api/cfradar', cacheable(ONE_WEEK_CACHE), cfHander); +app.get('/api/asn-history', requireValidPrefix(), cacheable(ONE_WEEK_CACHE), asnHistoryHandler); +app.get('/api/asn-connectivity', requireValidASN(), cacheable(ONE_WEEK_CACHE), asnConnectivityHandler); app.get('/api/whois', cacheable(ONE_HOUR_CACHE), getWhois); app.get('/api/ipapiis', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipapiisHandler); app.get('/api/ip2location', requireValidIP(), cacheable(ONE_HOUR_CACHE), ip2locationHandler); @@ -218,18 +222,20 @@ const __dirname = path.dirname(__filename); app.use(express.static(path.join(__dirname, './dist'))); -// Bootstrap MaxMind before accepting traffic so we never serve mid-download. -// Each step is non-fatal: a failure here leaves the MaxMind API returning -// 503 until the watcher picks up valid databases later. +// Bootstrap every offline dataset (MaxMind, CAIDA) before accepting traffic +// so we never serve mid-download. Each step is non-fatal: a failure leaves +// the dependent API in a degraded state (MaxMind → 503; CAIDA → empty graph +// or RIPEstat fallback) but doesn't block the listener. async function bootBackend() { await bootstrapMaxMindIfMissing({ reload: reloadMaxMindDatabases }); - await reloadMaxMindDatabases('startup').catch(() => { logger.error('❌ MaxMind API will return 503 until databases are loaded successfully'); }); + await bootstrapCaidaIfMissing(); startMaxMindFileWatcher(); startMaxMindAutoUpdate({ reload: reloadMaxMindDatabases }); + startCaidaAutoUpdate(); app.listen(backEndPort, () => { logger.info(`🚀 Backend server ready on http://localhost:${backEndPort}`); diff --git a/common/as-org-db.js b/common/as-org-db.js new file mode 100644 index 000000000..67b6b5ae7 --- /dev/null +++ b/common/as-org-db.js @@ -0,0 +1,93 @@ +// Local CAIDA as2org lookup. Snapshot file is auto-downloaded by +// common/caida-updater.js; this module just parses and serves. +// +// We use CAIDA's pipe-delimited TXT (~12MB) rather than the equivalent +// JSONL (~28MB): identical content but split('|') beats JSON.parse per +// line by ~40%. +// +// Source: https://publicdata.caida.org/datasets/as-organizations/ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import logger from './logger.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const AS_ORG_DB_DIR = path.join(__dirname, 'as-org-db'); + +// Canonical filename the updater writes to. Manual downloads can use any +// *.txt name — findSnapshot picks the newest by mtime regardless. +export const AS_ORG_FILE = 'as-org2info.txt'; + +const asnToOrgName = new Map(); +let loadedFrom = null; + +function findSnapshot() { + if (!fs.existsSync(AS_ORG_DB_DIR)) return null; + const txts = fs.readdirSync(AS_ORG_DB_DIR).filter(f => f.endsWith('.txt')); + if (txts.length === 0) return null; + const withMtime = txts.map(f => { + const full = path.join(AS_ORG_DB_DIR, f); + return { full, mtimeMs: fs.statSync(full).mtimeMs }; + }); + withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs); + return withMtime[0].full; +} + +// CAIDA TXT has two sections: org records (5 fields) and ASN records (6 +// fields). Orgs may appear after ASNs in the stream, so we collect ASN +// rows into `pending` first and resolve them after a full scan. +function parsePipeText(text) { + const orgs = new Map(); // org_id → name + const pending = []; // [{asn, org_id}] + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const parts = line.split('|'); + if (parts.length === 5) { + // org_id | changed | name | country | source + if (parts[0] && parts[2]) orgs.set(parts[0], parts[2]); + } else if (parts.length === 6) { + // aut | changed | aut_name | org_id | opaque_id | source + const asn = Number(parts[0]); + if (asn && parts[3]) pending.push({ asn, org_id: parts[3] }); + } + } + for (const { asn, org_id } of pending) { + const name = orgs.get(org_id); + if (name) asnToOrgName.set(asn, name); + } +} + +function loadDatabase() { + const filePath = findSnapshot(); + if (!filePath) { + logger.warn({ dir: AS_ORG_DB_DIR }, '⚠️ CAIDA as2org snapshot not found; ASN org-name lookups will fall back to RIPEstat'); + asnToOrgName.clear(); + loadedFrom = null; + return; + } + const start = Date.now(); + try { + const text = fs.readFileSync(filePath, 'utf8'); + asnToOrgName.clear(); + parsePipeText(text); + loadedFrom = path.basename(filePath); + logger.info(`📦 CAIDA as2org loaded (${loadedFrom}) — ${asnToOrgName.size} ASNs in ${Date.now() - start}ms`); + } catch (error) { + logger.warn({ err: error, path: filePath }, '⚠️ Failed to parse CAIDA as2org snapshot'); + } +} + +loadDatabase(); + +/** Reload the snapshot after caida-updater publishes a fresh file. */ +export function reloadAsOrgDatabase(reason = 'reload') { + logger.info(`🔄 Reloading CAIDA as2org snapshot (${reason})`); + loadDatabase(); +} + +/** Org name for an ASN, or null when the snapshot doesn't have it. */ +export function lookupAsOrgName(asn) { + return asnToOrgName.get(Number(asn)) || null; +} diff --git a/common/as-rel-db.js b/common/as-rel-db.js new file mode 100644 index 000000000..6151a8c78 --- /dev/null +++ b/common/as-rel-db.js @@ -0,0 +1,126 @@ +// Local CAIDA AS Relationships lookup. Snapshot is downloaded +// by common/caida-updater.js; this module parses and serves. +// +// CAIDA as-rel2 row format: `|||` +// rel = -1 → p2c (a is provider of b) ← we keep these +// rel = 0 → p2p (peering) ← skipped +// rel = 1 → s2s (sibling) ← skipped +// +// Source: https://publicdata.caida.org/datasets/as-relationships/serial-2/ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import logger from './logger.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const AS_REL_DB_DIR = path.join(__dirname, 'as-rel-db'); +export const AS_REL_FILE = 'as-rel2.txt'; + +// customer ASN → Set. Hot path of asn-connectivity. +const providersIndex = new Map(); + +// ASN → number of distinct customers it provides transit for. Ranking +// signal when an intermediate has more providers than MAX_INTERMEDIATE_BRANCH +// can show; roughly correlates with customer-cone size. +const customerCount = new Map(); + +// Tier 1 set, derived from the snapshot rather than hardcoded: +// (a) the AS has no providers in CAIDA's p2c topology (nobody sells +// transit to it ≈ CAIDA's clique notion), AND +// (b) it provides transit to at least TIER1_MIN_CUSTOMERS ASes — filters +// out defunct ASNs that look settlement-free only because they're +// inactive (e.g. AS1239 Sprint after the T-Mobile merger). +// 100 is the empirical sweet spot: captures Telxius/Orange/Liberty +// (customer cones 110-340) without admitting clearly defunct ASNs. +const TIER1_MIN_CUSTOMERS = 100; +const tier1Set = new Set(); + +let loadedFrom = null; + +function findSnapshot() { + if (!fs.existsSync(AS_REL_DB_DIR)) return null; + const txts = fs.readdirSync(AS_REL_DB_DIR).filter(f => f.endsWith('.txt')); + if (txts.length === 0) return null; + const withMtime = txts.map(f => { + const full = path.join(AS_REL_DB_DIR, f); + return { full, mtimeMs: fs.statSync(full).mtimeMs }; + }); + withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs); + return withMtime[0].full; +} + +function parsePipeText(text) { + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const parts = line.split('|'); + if (parts.length < 3 || parts[2] !== '-1') continue; + const provider = Number(parts[0]); + const customer = Number(parts[1]); + if (!provider || !customer) continue; + let set = providersIndex.get(customer); + if (!set) { + set = new Set(); + providersIndex.set(customer, set); + } + set.add(provider); + customerCount.set(provider, (customerCount.get(provider) || 0) + 1); + } +} + +function rebuildTier1Set() { + tier1Set.clear(); + for (const [asn, count] of customerCount) { + if (providersIndex.has(asn)) continue; // has upstream → not Tier 1 + if (count >= TIER1_MIN_CUSTOMERS) tier1Set.add(asn); + } +} + +function loadDatabase() { + const filePath = findSnapshot(); + if (!filePath) { + logger.warn({ dir: AS_REL_DB_DIR }, '⚠️ CAIDA as-rel snapshot not found; asn-connectivity will return empty graphs until the updater downloads one'); + providersIndex.clear(); + customerCount.clear(); + tier1Set.clear(); + loadedFrom = null; + return; + } + const start = Date.now(); + try { + const text = fs.readFileSync(filePath, 'utf8'); + providersIndex.clear(); + customerCount.clear(); + parsePipeText(text); + rebuildTier1Set(); + loadedFrom = path.basename(filePath); + logger.info(`📦 CAIDA as-rel loaded (${loadedFrom}) — ${providersIndex.size} customers, ${customerCount.size} providers, ${tier1Set.size} Tier 1s in ${Date.now() - start}ms`); + } catch (error) { + logger.warn({ err: error, path: filePath }, '⚠️ Failed to parse CAIDA as-rel snapshot'); + } +} + +loadDatabase(); + +/** Reload the snapshot after caida-updater publishes a fresh file. */ +export function reloadAsRelDatabase(reason = 'reload') { + logger.info(`🔄 Reloading CAIDA as-rel snapshot (${reason})`); + loadDatabase(); +} + +/** Providers (transit upstreams) of an ASN. Empty array when not in dataset. */ +export function providersOf(asn) { + const set = providersIndex.get(Number(asn)); + return set ? [...set] : []; +} + +/** How many distinct ASes this one provides transit for. 0 when never a provider. */ +export function customerCountOf(asn) { + return customerCount.get(Number(asn)) || 0; +} + +/** Whether this ASN is in the CAIDA-derived Tier 1 set. */ +export function isTier1(asn) { + return tier1Set.has(Number(asn)); +} diff --git a/common/caida-updater.js b/common/caida-updater.js new file mode 100644 index 000000000..20c98739f --- /dev/null +++ b/common/caida-updater.js @@ -0,0 +1,364 @@ +// Vendor-scoped CAIDA auto-updater. Each dataset is a row in `datasets` +// below; the orchestration (lock / state / atomic publish / validation / +// reload) is shared. Mirrors common/maxmind-updater.js's editions-array +// pattern. +// +// Registered datasets: +// - as2org (AS → org-name mapping) common/as-org-db +// - as-rel (AS relationships, p2c) common/as-rel-db +// +// Both share CAIDA_AUTO_UPDATE=true to gate the periodic scheduler +// (off by default); bootstrap always runs so a fresh checkout works. + +import fs from 'fs'; +import fsp from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; +import logger from './logger.js'; +import { createDecompressor } from './decompress.js'; +import { AS_ORG_DB_DIR, AS_ORG_FILE, reloadAsOrgDatabase } from './as-org-db.js'; +import { AS_REL_DB_DIR, AS_REL_FILE, reloadAsRelDatabase } from './as-rel-db.js'; + +const UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000; +const INITIAL_UPDATE_DELAY_MS = 60 * 1000; +const LOCK_STALE_MS = 2 * 60 * 60 * 1000; +const BOOTSTRAP_TIMEOUT_MS = 2 * 60 * 1000; +const STATE_FILE = '.caida-update-state.json'; +const LOCK_FILE = '.caida-update.lock'; + +const datasets = [ + { + id: 'as2org', + dbDir: AS_ORG_DB_DIR, + canonicalFile: AS_ORG_FILE, + archiveExt: '.txt.gz', + decompress: 'gzip', + // Stable 'latest' symlink server-side → HEAD + Last-Modified is enough. + findRemote: async ({ signal } = {}) => { + const url = 'https://publicdata.caida.org/datasets/as-organizations/latest.as-org2info.txt.gz'; + const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal }); + if (!res.ok) throw new Error(`HEAD failed: HTTP ${res.status}`); + return { url, identifier: res.headers.get('last-modified') }; + }, + validate: validateAsOrg, + reload: reloadAsOrgDatabase, + }, + { + id: 'as-rel', + dbDir: AS_REL_DB_DIR, + canonicalFile: AS_REL_FILE, + archiveExt: '.txt.bz2', + decompress: 'bzip2', + // No 'latest' symlink — scrape directory listing, lex-sort YYYYMMDD + // filenames (= chrono), take the newest. + findRemote: async ({ signal } = {}) => { + const dir = 'https://publicdata.caida.org/datasets/as-relationships/serial-2/'; + const res = await fetch(dir, { signal }); + if (!res.ok) throw new Error(`Directory listing failed: HTTP ${res.status}`); + const html = await res.text(); + const matches = [...html.matchAll(/\b(\d{8})\.as-rel2\.txt\.bz2\b/g)]; + if (matches.length === 0) throw new Error('No as-rel2.txt.bz2 found in listing'); + matches.sort((a, b) => b[1].localeCompare(a[1])); + const filename = matches[0][0]; + return { url: dir + filename, identifier: filename }; + }, + validate: validateAsRel, + reload: reloadAsRelDatabase, + }, +]; + +let schedulerStarted = false; +const updateInProgress = new Set(); + +// ---------- Public API ---------- + +/** + * Download any missing CAIDA snapshots at boot. Always runs regardless of + * CAIDA_AUTO_UPDATE — a missing snapshot at boot means the operator wants + * one. Each dataset is independent; one failing doesn't abort others. + * Never throws. + */ +export async function bootstrapCaidaIfMissing() { + for (const dataset of datasets) { + try { + await bootstrapDataset(dataset); + } catch (error) { + logger.warn({ err: error, dataset: dataset.id }, '⚠️ CAIDA bootstrap failed'); + } + } +} + +/** Start the periodic updater. Opt-in via CAIDA_AUTO_UPDATE=true. */ +export function startCaidaAutoUpdate() { + if (schedulerStarted) return; + schedulerStarted = true; + if (!isAutoUpdateEnabled()) return; + + const run = () => { + runAllUpdates().catch(error => { + logger.error({ err: error }, 'CAIDA auto update tick failed'); + }); + }; + setTimeout(run, INITIAL_UPDATE_DELAY_MS).unref?.(); + setInterval(run, UPDATE_INTERVAL_MS).unref?.(); + + const nextRunAt = new Date(Date.now() + INITIAL_UPDATE_DELAY_MS); + logger.info(`🗓️ CAIDA auto update plan: next check at ${nextRunAt.toLocaleString('en-US', { hour12: false })}, then every 24 hours (${datasets.length} datasets)`); +} + +// ---------- Orchestration ---------- + +async function runAllUpdates() { + for (const dataset of datasets) { + try { + await updateDataset(dataset); + } catch (error) { + logger.error({ err: error, dataset: dataset.id }, 'CAIDA update failed'); + } + } +} + +async function bootstrapDataset(dataset) { + await fsp.mkdir(dataset.dbDir, { recursive: true }); + await clearOrphanedLock(dataset); + + if (snapshotExists(dataset)) return { status: 'present' }; + + const timeoutMinutes = BOOTSTRAP_TIMEOUT_MS / 60000; + logger.warn(`📥 CAIDA ${dataset.id} snapshot missing; attempting initial download (timeout ${timeoutMinutes} min)...`); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error('bootstrap timed out')), BOOTSTRAP_TIMEOUT_MS); + timer.unref?.(); + + try { + const result = await updateDataset(dataset, { + signal: controller.signal, + reloadReason: 'bootstrap', + }); + if (result.updated) { + logger.warn(`✅ CAIDA ${dataset.id} snapshot downloaded and ready`); + return { status: 'downloaded' }; + } + logger.warn(`⚠️ CAIDA ${dataset.id} bootstrap did not publish (${result.reason}).`); + return { status: 'no-op', reason: result.reason }; + } catch (error) { + const reason = controller.signal.aborted + ? `download did not complete within ${timeoutMinutes} min` + : error.message; + logger.warn(`⚠️ CAIDA ${dataset.id} initial download failed: ${reason}`); + return { status: 'failed', error }; + } finally { + clearTimeout(timer); + } +} + +async function updateDataset(dataset, { signal, reloadReason = 'auto update' } = {}) { + if (updateInProgress.has(dataset.id)) { + return { updated: false, reason: 'already-running' }; + } + updateInProgress.add(dataset.id); + + await fsp.mkdir(dataset.dbDir, { recursive: true }); + + const lock = await acquireUpdateLock(dataset); + if (!lock) { + updateInProgress.delete(dataset.id); + return { updated: false, reason: 'locked' }; + } + + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), `myip-caida-${dataset.id}-`)); + + try { + const result = await downloadAndPublish(dataset, tempDir, { signal }); + if (result.updated && dataset.reload) { + dataset.reload(reloadReason); + } + return result; + } finally { + updateInProgress.delete(dataset.id); + await fsp.rm(tempDir, { recursive: true, force: true }); + await lock.release(); + } +} + +async function downloadAndPublish(dataset, tempDir, { signal } = {}) { + const state = await readUpdateState(dataset); + const remote = await dataset.findRemote({ signal }); + const targetPath = path.join(dataset.dbDir, dataset.canonicalFile); + + if (fs.existsSync(targetPath) && state.identifier && state.identifier === remote.identifier) { + return { updated: false, reason: 'not-modified' }; + } + + const stagedPath = await downloadAndDecompress(dataset, remote.url, tempDir, { signal }); + await dataset.validate(stagedPath); + await publishFile(stagedPath, targetPath); + + await writeUpdateState(dataset, { + identifier: remote.identifier, + updatedAt: new Date().toISOString(), + }); + + logger.info({ dataset: dataset.id, identifier: remote.identifier }, 'CAIDA snapshot updated'); + return { updated: true, identifier: remote.identifier }; +} + +// Stream archive to disk, then decompress to staged .txt. Split so errors +// clearly identify which phase failed (network vs decompression). +async function downloadAndDecompress(dataset, url, tempDir, { signal } = {}) { + const archivePath = path.join(tempDir, `archive${dataset.archiveExt}`); + const stagedPath = path.join(tempDir, dataset.canonicalFile); + + const response = await fetch(url, { redirect: 'follow', signal }); + if (!response.ok || !response.body) { + throw new Error(`Failed to download ${dataset.id}: HTTP ${response.status}`); + } + try { + await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(archivePath), { signal }); + } catch (error) { + throw new Error(`${dataset.id} download phase failed: ${error.message}`); + } + + try { + await pipeline( + fs.createReadStream(archivePath), + createDecompressor(dataset.decompress), + fs.createWriteStream(stagedPath), + ); + } catch (error) { + throw new Error(`${dataset.id} ${dataset.decompress} decompression phase failed: ${error.message}`); + } + + return stagedPath; +} + +// ---------- Validators (per-dataset, refuse partial / corrupt downloads) ---------- + +// Healthy as2org resolves ~110k ASNs; <50k indicates truncation or schema change. +async function validateAsOrg(stagedPath) { + const MIN_VALID = 50000; + const text = await fsp.readFile(stagedPath, 'utf8'); + const orgs = new Map(); + const pending = []; + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const parts = line.split('|'); + if (parts.length === 5 && parts[0] && parts[2]) { + orgs.set(parts[0], parts[2]); + } else if (parts.length === 6 && Number(parts[0]) && parts[3]) { + pending.push(parts[3]); + } + } + let resolved = 0; + for (const orgId of pending) if (orgs.has(orgId)) resolved++; + if (resolved < MIN_VALID) { + throw new Error(`Staged as2org has only ${resolved} resolvable ASNs; refusing to publish`); + } +} + +// Healthy as-rel2 has ~400k p2c rows; <100k indicates truncation. +async function validateAsRel(stagedPath) { + const MIN_VALID = 100000; + const text = await fsp.readFile(stagedPath, 'utf8'); + let p2cRows = 0; + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const parts = line.split('|'); + if (parts.length >= 3 && parts[2] === '-1') p2cRows++; + } + if (p2cRows < MIN_VALID) { + throw new Error(`Staged as-rel has only ${p2cRows} p2c rows; refusing to publish`); + } +} + +// ---------- Generic helpers ---------- + +async function publishFile(stagedPath, targetPath) { + const nextPath = `${targetPath}.next`; + const backupPath = `${targetPath}.bak`; + await fsp.copyFile(stagedPath, nextPath); + await fsp.copyFile(targetPath, backupPath).catch(error => { + if (error.code !== 'ENOENT') throw error; + }); + try { + await fsp.rename(nextPath, targetPath); + } catch (error) { + await fsp.copyFile(backupPath, targetPath).catch(() => {}); + throw error; + } finally { + await Promise.all([ + fsp.rm(nextPath, { force: true }), + fsp.rm(backupPath, { force: true }), + ]); + } +} + +function isAutoUpdateEnabled() { + return process.env.CAIDA_AUTO_UPDATE === 'true'; +} + +function snapshotExists(dataset) { + if (!fs.existsSync(dataset.dbDir)) return false; + return fs.readdirSync(dataset.dbDir).some(f => f.endsWith('.txt')); +} + +// At boot, any pre-existing lock is necessarily from a previous crashed run +// (we just started, no in-process updater can hold it). Clear it so a dead +// Ctrl+C doesn't block restart for LOCK_STALE_MS. +async function clearOrphanedLock(dataset) { + const lockPath = path.join(dataset.dbDir, LOCK_FILE); + if (fs.existsSync(lockPath)) { + await fsp.rm(lockPath, { force: true }); + logger.warn(`🧹 Cleared orphaned CAIDA ${dataset.id} lock from previous boot`); + } +} + +async function readUpdateState(dataset) { + try { + const content = await fsp.readFile(path.join(dataset.dbDir, STATE_FILE), 'utf8'); + return JSON.parse(content); + } catch (error) { + if (error.code === 'ENOENT') return {}; + throw error; + } +} + +async function writeUpdateState(dataset, state) { + await fsp.writeFile( + path.join(dataset.dbDir, STATE_FILE), + `${JSON.stringify(state, null, 2)}\n`, + 'utf8', + ); +} + +async function acquireUpdateLock(dataset) { + const lockPath = path.join(dataset.dbDir, LOCK_FILE); + try { + const handle = await fsp.open(lockPath, 'wx'); + await handle.writeFile(JSON.stringify({ + pid: process.pid, + dataset: dataset.id, + startedAt: new Date().toISOString(), + })); + return { + release: async () => { + await handle.close(); + await fsp.rm(lockPath, { force: true }); + }, + }; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + const stat = await fsp.stat(lockPath).catch(() => null); + if (stat && Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + await fsp.rm(lockPath, { force: true }); + return acquireUpdateLock(dataset); + } + logger.info(`CAIDA ${dataset.id} update skipped: another process is updating`); + return null; + } +} diff --git a/common/decompress.js b/common/decompress.js new file mode 100644 index 000000000..d5b1dfdd1 --- /dev/null +++ b/common/decompress.js @@ -0,0 +1,17 @@ +// Single entry point for stream decompression. New formats become a switch +// case here rather than a new dependency in every updater file. +// +// gzip → Node built-in zlib (no dep) +// bzip2 → unbzip2-stream (Node has no native bzip2) + +import zlib from 'zlib'; +import unbzip2Stream from 'unbzip2-stream'; + +export function createDecompressor(format) { + switch (format) { + case 'gzip': return zlib.createGunzip(); + case 'bzip2': return unbzip2Stream(); + default: + throw new Error(`Unsupported decompression format: ${format}`); + } +} diff --git a/common/guards.js b/common/guards.js index 33d9d0935..00c04aaa1 100644 --- a/common/guards.js +++ b/common/guards.js @@ -46,3 +46,19 @@ export const requireValidPrefix = (paramName = 'prefix') => (req, res, next) => } next(); }; + +// Reject requests without a valid ASN (numeric, with optional 'AS' prefix). +// Used by /api/asn-connectivity; other ASN-taking handlers (cf-radar) still +// validate inline for historical reasons. +export const requireValidASN = (paramName = 'asn') => (req, res, next) => { + const raw = req.query[paramName]; + if (!raw) { + return res.status(400).json({ error: 'No ASN provided' }); + } + const numeric = String(raw).replace(/^AS/i, ''); + if (!/^[0-9]+$/.test(numeric)) { + return res.status(400).json({ error: 'Invalid ASN' }); + } + req.query[paramName] = numeric; + next(); +}; diff --git a/common/ripestat.js b/common/ripestat.js new file mode 100644 index 000000000..b7a649688 --- /dev/null +++ b/common/ripestat.js @@ -0,0 +1,34 @@ +// Centralized RIPEstat client. Each typed helper picks a timeout matching +// its endpoint's normal latency; callers can override per call. + +import { fetchUpstream } from './fetch-with-timeout.js'; + +const BASE_URL = 'https://stat.ripe.net/data'; +const SOURCE_APP = process.env.RIPESTAT_SOURCE_APP || 'myip'; + +function fetchRipestat(endpoint, resource, { timeoutMs = 8000 } = {}) { + const search = new URLSearchParams({ resource, sourceapp: SOURCE_APP }); + return fetchUpstream(`${BASE_URL}/${endpoint}/data.json?${search}`, { timeoutMs }); +} + +/** as-overview: AS metadata including `holder` (org name w/ handle prefix). */ +export function fetchAsOverview(asn, { timeoutMs = 3000 } = {}) { + return fetchRipestat('as-overview', `AS${asn}`, { timeoutMs }); +} + +/** routing-history: historical AS announcements for a prefix or IP. + * Slow analytical endpoint — scans years of BGP data, regularly takes + * 10–20s for prefixes with long history. */ +export function fetchRoutingHistory(resource, { timeoutMs = 25000 } = {}) { + return fetchRipestat('routing-history', resource, { timeoutMs }); +} + +/** + * Strip RIPEstat's " - " prefix from a `holder` field so the UI + * sees just the readable company name. + */ +export function extractOrgFromHolder(holder) { + if (!holder || typeof holder !== 'string') return null; + const dash = holder.indexOf(' - '); + return dash > 0 ? holder.slice(dash + 3).trim() : holder.trim(); +} diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 6fd9e5cb1..9c07026f6 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -44,7 +44,7 @@ frontend/ │ EnhancedDnsLeakTest / CensorshipCheck / Whois / │ MacChecker / BrowserInfo / InvisibilityTest / │ SecurityChecklist + Empty) - ├── widgets/ ← small reusables (QueryIP / Help / Preferences / InfoMask / PWA / Toast / FitText) + ├── widgets/ ← small reusables (QueryIP / Help / Preferences / InfoMask / PWA / Toast / FitText / CopyButton) ├── svgicons/ ← a few inline SVGs └── ui/ ← shadcn-vue copy-in primitives (see "UI system" below) ``` diff --git a/frontend/components/Advanced.vue b/frontend/components/Advanced.vue index 1056077b6..4473ad8a7 100644 --- a/frontend/components/Advanced.vue +++ b/frontend/components/Advanced.vue @@ -2,11 +2,15 @@
-
+
+

🧰 {{ t('advancedtools.Title') }}

-

{{ t('advancedtools.Note') }}

+
+
+

{{ t('advancedtools.Note') }}

+
@@ -79,7 +83,8 @@ const { t } = useI18n(); const store = useMainStore(); const isMobile = computed(() => store.isMobile); const configs = computed(() => store.configs); - +const userPreferences = computed(() => store.userPreferences); +const isSimpleMode = computed(() => userPreferences.value.simpleMode); const scrollContainer = ref(null); const router = useRouter(); @@ -87,7 +92,7 @@ const cards = reactive([ { path: '/pingtest', icon: '⏱️', titleKey: 'pingtest.Title', noteKey: 'advancedtools.PingTestNote', enabled: true }, { path: '/mtrtest', icon: '📡', titleKey: 'mtrtest.Title', noteKey: 'advancedtools.MTRTestNote', enabled: true }, { path: '/ruletest', icon: '🚏', titleKey: 'ruletest.Title', noteKey: 'advancedtools.RuleTestNote', enabled: true }, - { path: '/dnsresolver', icon: '🔦', titleKey: 'dnsresolver.Title', noteKey: 'advancedtools.DNSResolverNote', enabled: true }, + { path: '/dnsresolver', icon: '📟', titleKey: 'dnsresolver.Title', noteKey: 'advancedtools.DNSResolverNote', enabled: true }, { path: '/censorshipcheck', icon: '🚧', titleKey: 'censorshipcheck.Title', noteKey: 'advancedtools.CensorshipCheck', enabled: true }, { path: '/whois', icon: '📓', titleKey: 'whois.Title', noteKey: 'advancedtools.Whois', enabled: true }, { path: '/macchecker', icon: '🗄️', titleKey: 'macchecker.Title', noteKey: 'advancedtools.MacChecker', enabled: true }, @@ -150,7 +155,7 @@ const resetNavigatorURL = () => { let invisibilityEnableTimer = null; onMounted(() => { - store.setMountingStatus('advancedtools', true); + store.setMountingStatus('AdvancedTools', true); invisibilityEnableTimer = setTimeout(() => { invisibilityEnableTimer = null; if (configs.value.originalSite) { diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index cce78aa05..0bba09bfc 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -14,7 +14,7 @@
-

{{ t('connectivity.Note') }}

+

{{ t('connectivity.Note') }}

@@ -133,7 +133,7 @@ import { const { t } = useI18n(); const store = useMainStore(); const userPreferences = computed(() => store.userPreferences); - +const isSimpleMode = computed(() => userPreferences.value.simpleMode); const alertToShow = ref(false); const alertStyle = ref(""); const alertTitle = ref(""); @@ -467,7 +467,7 @@ const handelCheckStart = async (fromApp = false) => { const multi = multipleTests.value; if (fromApp) await checkAllConnectivity(false, true, true); else await checkAllConnectivity(true, false, false); - store.setLoadingStatus('connectivity', true); + store.setLoadingStatus('Connectivity', true); if (multi) { intervalId.value = setInterval(async () => { if (counter.value < maxCounts.value && !manualRun.value) { @@ -492,7 +492,7 @@ const handelCheckStart = async (fromApp = false) => { }; onMounted(() => { - store.setMountingStatus('connectivity', true); + store.setMountingStatus('Connectivity', true); }); // Stop the interval on unmount. diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index 03ed74fbc..8077309a5 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -6,8 +6,7 @@

- - {{ t('dnsleaktest.Title') }} + 🛑 {{ t('dnsleaktest.Title') }}

-

{{ t('dnsleaktest.Note') }}

-

{{ t('dnsleaktest.Note2') }}

+

{{ t('dnsleaktest.Note') }}

+

{{ t('dnsleaktest.Note2') }}

@@ -131,7 +130,8 @@ const store = useMainStore(); const router = useRouter(); const lang = computed(() => store.lang); const isStarted = ref(false); - +const userPreferences = computed(() => store.userPreferences); +const isSimpleMode = computed(() => userPreferences.value.simpleMode); // Sticky flag for the Enhanced DNS Leak Test banner. const hasEverSettled = ref(false); @@ -292,14 +292,14 @@ const checkAllDNSLeakTest = async (isRefresh) => { const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000)); return Promise.race([allSettledPromise, timeoutPromise]).then(() => { - store.setLoadingStatus('dnsleaktest', true); + store.setLoadingStatus('DNSLeakTest', true); // Local sticky flag for the Enhanced DNS Leak Test banner hasEverSettled.value = true; }); }; onMounted(() => { - store.setMountingStatus('dnsleaktest', true); + store.setMountingStatus('DNSLeakTest', true); }); defineExpose({ diff --git a/frontend/components/Footer.vue b/frontend/components/Footer.vue index 6d831c514..1619e3f98 100644 --- a/frontend/components/Footer.vue +++ b/frontend/components/Footer.vue @@ -181,6 +181,8 @@ const thanksList = [ { name: 'Globalping by jsDelivr', link: 'https://globalping.io/' }, { name: 'ProxyCheck.io', link: 'https://proxycheck.io/' }, { name: 'Digital Defense', link: 'https://digital-defense.io/' }, + { name: 'RIPE NCC', link: 'https://stat.ripe.net/' }, + { name: 'CAIDA', link: 'https://www.caida.org/' }, { name: 'ChatGPT', link: 'https://chatgpt.com/' }, { name: 'Claude', link: 'https://claude.ai/' }, ]; diff --git a/frontend/components/IpInfos.vue b/frontend/components/IpInfos.vue index a14609fc5..1b64a4261 100644 --- a/frontend/components/IpInfos.vue +++ b/frontend/components/IpInfos.vue @@ -4,10 +4,10 @@

- 🔎 {{ t('ipInfos.Title') }} + 🔦 {{ t('ipInfos.Title') }}

-

{{ t('ipInfos.Notes') }}

+

{{ t('ipInfos.Notes') }}

@@ -18,9 +18,9 @@ :id="'IPInfoCard-' + (index + 1)" class="flex" :class="{ 'opacity-60': !card.ip || card.ip === t('ipInfos.IPv4Error') || card.ip === t('ipInfos.IPv6Error') }"> + :asnConnectivityInfos="asnConnectivityInfos" @refresh-card="refreshCard" />
@@ -48,9 +48,7 @@ const isMobile = computed(() => store.isMobile); const configs = computed(() => store.configs); const userPreferences = computed(() => store.userPreferences); const lang = computed(() => store.lang); - -// Dynamic configuration of the page -const isCardsCollapsed = computed(() => userPreferences.value.simpleMode); +const isSimpleMode = computed(() => userPreferences.value.simpleMode); // Default card data const createDefaultCard = () => ({ @@ -114,9 +112,11 @@ const asnInfos = ref({ // Session cache — wipes on reload. const asnHistoryInfos = ref({}); +// ASN upstream connectivity graph, keyed by numeric ASN string. Session cache. +const asnConnectivityInfos = ref({}); + // Other data const ipCardsToShow = ref(userPreferences.value.ipCardsToShow); -const copiedStatus = ref({}); const IPArray = ref([]); const ipGeoSource = ref(userPreferences.value.ipGeoSource); const usingSource = ref(userPreferences.value.ipGeoSource); @@ -159,7 +159,7 @@ const trackFetchStatus = (status) => { } } if (allHasFetched) { - store.setLoadingStatus('ipcheck', true); + store.setLoadingStatus('IPInfo', true); } }; @@ -335,7 +335,7 @@ watch(IPArray, () => { }); onMounted(() => { - store.setMountingStatus('ipcheck', true); + store.setMountingStatus('IPInfo', true); }); defineExpose({ diff --git a/frontend/components/Nav.vue b/frontend/components/Nav.vue index 87d8bc170..242770f51 100644 --- a/frontend/components/Nav.vue +++ b/frontend/components/Nav.vue @@ -15,9 +15,9 @@
- - IPCheck. + IPCheck. + ing @@ -26,6 +26,7 @@
{{ t(`nav.${item}`) }} @@ -190,6 +191,7 @@ import { LogOut, Menu, SlidersHorizontal, } from 'lucide-vue-next'; import brandIcon from './svgicons/Brand.vue'; +import { SECTION_IDS } from '@/data/sections'; const { t } = useI18n(); const store = useMainStore(); @@ -201,7 +203,7 @@ const currentSection = computed(() => store.currentSection); const loaded = ref(false); // Navigation items (desktop + mobile share the same list) -const navItems = ['IPInfo', 'Connectivity', 'WebRTC', 'DNSLeakTest', 'SpeedTest', 'AdvancedTools']; +const navItems = SECTION_IDS; // nav link style — current section highlight use bg-accent instead of only bold const navLinkClass = (item, { block = false } = {}) => { diff --git a/frontend/components/SpeedTest.vue b/frontend/components/SpeedTest.vue index 7ede96f4a..58e59650e 100644 --- a/frontend/components/SpeedTest.vue +++ b/frontend/components/SpeedTest.vue @@ -2,12 +2,16 @@
-
-

- 🚀 {{ t('speedtest.Title') }} -

-

{{ t('speedtest.Note') }}

+
+
+

+ 🚀 {{ t('speedtest.Title') }} +

+
+
+

{{ t('speedtest.Note') }}

+
@@ -124,17 +128,25 @@ ( {{ state.connection.coloCity }}, {{ state.connection.coloCountry }} ) {{ t('speedtest.connectionEnd') }}

+ +

+ {{ t('speedtest.loadedLatency') }} + ↓ {{ state.speedTest.downLoadedLatency }} ms + · + ↑ {{ state.speedTest.upLoadedLatency }} ms +

{{ t('speedtest.score') }} {{ t('speedtest.videoStreaming') }} - {{ t('speedtest.quality.' + + {{ t('speedtest.quality.' + state.speedTest.streamingQuality) }} {{ t('speedtest.gaming') }} - + {{ t('speedtest.quality.' + state.speedTest.gamingQuality) }} {{ t('speedtest.rtc') }} - + {{ t('speedtest.quality.' + state.speedTest.rtcQuality) }}

@@ -158,7 +170,6 @@ import SpeedTestEngine from '@cloudflare/speedtest'; import useSpeedTestCharts from '@/utils/use-speedtest-charts.js'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -172,7 +183,8 @@ const { t } = useI18n(); const store = useMainStore(); const lang = computed(() => store.lang); const isSignedIn = computed(() => store.isSignedIn); - +const userPreferences = computed(() => store.userPreferences); +const isSimpleMode = computed(() => userPreferences.value.simpleMode); // State Management const state = reactive({ speedTest: { @@ -181,6 +193,8 @@ const state = reactive({ uploadSpeed: '-', latency: '-', jitter: '-', + downLoadedLatency: '-', + upLoadedLatency: '-', streamingScore: '-', streamingQuality: '-', gamingScore: '-', @@ -247,12 +261,20 @@ const metrics = computed(() => [ { key: 'jitter', label: t('speedtest.Jitter'), unit: 'ms' }, ]); -// Quality Badge Color: >=50 success, >=10 warning, else destructive -const qualityBadgeClass = (score) => { - if (score === '-' || score === undefined) return 'text-muted-foreground'; - if (score >= 50) return 'text-success'; - if (score >= 10) return 'text-warning'; - return 'text-destructive'; +// 5-tier classification → 3 color tokens (label carries the 5-way distinction). +const qualityBadgeClass = (classification) => { + switch (classification) { + case 'bad': + case 'poor': + return 'text-destructive'; + case 'average': + return 'text-warning'; + case 'good': + case 'great': + return 'text-success'; + default: + return 'text-muted-foreground'; + } }; // --- Connection Data ---------------------------------------------------------- @@ -289,15 +311,8 @@ const connectionMethods = { let testEngine; const engineMethods = { + // Build a fresh engine. State reset is owned by speedTestController. reset() { - state.speedTest.hasScores = false; - Object.assign(state.speedTest, { - downloadSpeed: 0, uploadSpeed: 0, latency: 0, jitter: 0, - streamingScore: '-', gamingScore: '-', rtcScore: '-', - progress: 0, - }); - if (testEngine) testEngine = null; - return markRaw(new SpeedTestEngine({ autoStart: false, measurements: [ @@ -315,101 +330,52 @@ const engineMethods = { uploadSpeed: parseFloat((summary.upload / 1000000).toFixed(2)), latency: parseFloat(summary.latency.toFixed(2)), jitter: parseFloat(summary.jitter.toFixed(2)), + // Loaded latency only present when the engine measured it. + downLoadedLatency: summary.downLoadedLatency == null ? '-' : parseFloat(summary.downLoadedLatency.toFixed(2)), + upLoadedLatency: summary.upLoadedLatency == null ? '-' : parseFloat(summary.upLoadedLatency.toFixed(2)), }); }, updateProgress() { const rawData = testEngine.results.raw; + // 3 stages: latency / download / upload. const perStage = 100 / 3; let progress = 0; + if (rawData.latency?.started) progress += rawData.latency.finished ? perStage : perStage / 2; if (rawData.download?.started) progress += rawData.download.finished ? perStage : perStage / 2; if (rawData.upload?.started) progress += rawData.upload.finished ? perStage : perStage / 2; - if (rawData.latency?.started) progress += rawData.latency.finished ? perStage : perStage / 2; state.speedTest.progress = Math.min(progress, 100); }, + // Live values via engine getters (supported API). Getters can briefly return + // `null` between "started" and "first datapoint", so guard with `!= null`. updateSpeedInRealTime() { if (state.speedTest.status === 'finished' || state.speedTest.status === 'error') return; try { - const rawData = testEngine?.results?.raw; - if (!rawData) return; - - if (rawData.latency?.started) { - if (rawData.latency?.results?.timings?.length > 0) { - const latencyTimings = rawData.latency.results.timings; - state.speedTest.latency = parseFloat(latencyTimings[latencyTimings.length - 1].ping.toFixed(2)); - - if (latencyTimings.length >= 2) { - const differences = []; - for (let i = 1; i < latencyTimings.length; i++) { - differences.push(Math.abs(latencyTimings[i].ping - latencyTimings[i - 1].ping)); - } - const mean = differences.reduce((a, b) => a + b, 0) / differences.length; - const variance = differences.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / differences.length; - state.speedTest.jitter = parseFloat(Math.sqrt(variance).toFixed(2)); - } - } - } - - if (rawData.download?.started) { - if (rawData.download.current?.timings?.length > 0) { - const timings = rawData.download.current.timings; - state.speedTest.downloadSpeed = parseFloat((timings[timings.length - 1].bps / 1000000).toFixed(2)); - } else if (rawData.download?.results) { - const downloadKeys = Object.keys(rawData.download.results); - if (downloadKeys.length > 0) { - const lastKey = downloadKeys[downloadKeys.length - 1]; - const downloadTimings = rawData.download.results[lastKey].timings; - if (downloadTimings?.length > 0) { - const latestTiming = downloadTimings[downloadTimings.length - 1]; - state.speedTest.downloadSpeed = parseFloat((latestTiming.bps / 1000000).toFixed(2)); - } - } - } else { - state.speedTest.downloadSpeed = 0; - } - } - - if (rawData.upload?.started) { - if (rawData.upload.current?.timings?.length > 0) { - const timings = rawData.upload.current.timings; - state.speedTest.uploadSpeed = parseFloat((timings[timings.length - 1].bps / 1000000).toFixed(2)); - } else if (rawData.upload?.results) { - const uploadKeys = Object.keys(rawData.upload.results); - if (uploadKeys.length > 0) { - const lastKey = uploadKeys[uploadKeys.length - 1]; - const uploadTimings = rawData.upload.results[lastKey].timings; - if (uploadTimings?.length > 0) { - const latestTiming = uploadTimings[uploadTimings.length - 1]; - state.speedTest.uploadSpeed = parseFloat((latestTiming.bps / 1000000).toFixed(2)); - } - } - } else { - state.speedTest.uploadSpeed = 0; - } - } + const results = testEngine?.results; + if (!results) return; + + const dl = results.getDownloadBandwidth(); + const ul = results.getUploadBandwidth(); + const lat = results.getUnloadedLatency(); + const jit = results.getUnloadedJitter(); + + if (dl != null) state.speedTest.downloadSpeed = parseFloat((dl / 1e6).toFixed(2)); + if (ul != null) state.speedTest.uploadSpeed = parseFloat((ul / 1e6).toFixed(2)); + if (lat != null) state.speedTest.latency = parseFloat(lat.toFixed(2)); + if (jit != null) state.speedTest.jitter = parseFloat(jit.toFixed(2)); updateCharts( state.speedTest.downloadSpeed, state.speedTest.uploadSpeed, state.speedTest.latency, state.speedTest.jitter, - rawData, + results.raw, ); } catch (error) { console.error('Error in updateSpeedInRealTime:', error); } }, - - updateLatency(rawData) { - if (!rawData.latency?.results?.timings?.length) return; - const latencyTimings = rawData.latency.results.timings; - const latestLatency = latencyTimings[latencyTimings.length - 1].ping; - const newLatency = parseFloat(latestLatency.toFixed(2)); - if (newLatency < state.speedTest.latency || state.speedTest.latency === 0) { - state.speedTest.latency = newLatency; - } - }, }; // --- Achievements -------------------------------------------------------------- @@ -464,15 +430,24 @@ const setupTestEngine = async () => { testEngine.onError = null; engineMethods.updateResults(results); + // Engine's getScores() reads getSummary() internally; when packetLoss is + // unmeasured it falls back to defaultPoints.packetLoss=0, which costs every + // experience a flat 10 points vs. an actually-measured 0% loss (+10 from + // the scaleThreshold formula). That single delta pushes gaming/rtc one or + // two tiers down. Since we don't run packetLoss (would need TURN setup), + // inject the optimistic 0 — matches Cloudflare's own UI behavior whenever + // the user's network has near-zero loss, which is the common case. + const origGetSummary = results.getSummary.bind(results); + results.getSummary = () => ({ ...origGetSummary(), packetLoss: 0 }); const scores = results.getScores(); if (scores?.streaming) { state.speedTest.hasScores = true; state.speedTest.streamingScore = scores.streaming.points; state.speedTest.gamingScore = scores.gaming.points; state.speedTest.rtcScore = scores.rtc.points; - state.speedTest.streamingQuality = scores.streaming.points >= 50 ? 'Good' : scores.streaming.points >= 10 ? 'Medium' : 'Bad'; - state.speedTest.gamingQuality = scores.gaming.points >= 50 ? 'Good' : scores.gaming.points >= 10 ? 'Medium' : 'Bad'; - state.speedTest.rtcQuality = scores.rtc.points >= 50 ? 'Good' : scores.rtc.points >= 10 ? 'Medium' : 'Bad'; + state.speedTest.streamingQuality = scores.streaming.classificationName; + state.speedTest.gamingQuality = scores.gaming.classificationName; + state.speedTest.rtcQuality = scores.rtc.classificationName; } testEngine = null; @@ -504,6 +479,7 @@ const speedTestController = async () => { Object.assign(state.speedTest, { downloadSpeed: 0, uploadSpeed: 0, latency: 0, jitter: 0, + downLoadedLatency: '-', upLoadedLatency: '-', streamingScore: '-', gamingScore: '-', rtcScore: '-', hasScores: false, progress: 0, }); @@ -522,7 +498,7 @@ const speedTestController = async () => { // --- Lifecycle ---------------------------------------------------------- -onMounted(() => { store.setMountingStatus('speedtest', true); }); +onMounted(() => { store.setMountingStatus('SpeedTest', true); }); // If the user navigates away mid-test, detach the engine's callbacks before // dropping the reference — otherwise any in-flight async work inside the // SpeedTestEngine would still try to write state refs that no longer exist. diff --git a/frontend/components/WebRtcTest.vue b/frontend/components/WebRtcTest.vue index 3d2a48c92..027a95b87 100644 --- a/frontend/components/WebRtcTest.vue +++ b/frontend/components/WebRtcTest.vue @@ -5,7 +5,7 @@

- 🚥 {{ t('webrtc.Title') }} + 🚱 {{ t('webrtc.Title') }}

-

{{ t('webrtc.Note') }}

+

{{ t('webrtc.Note') }}

@@ -42,8 +42,7 @@ class="absolute inline-flex size-2 rounded-full bg-info opacity-75 animate-ping"> -
@@ -73,6 +72,30 @@ + + + + + + + +
+
{{ stun.sdpLog.join('\n') }}
+ +
+
+
@@ -90,24 +113,28 @@ import getCountryName from '@/data/country-name.js'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; import { useStatusTone, ipFieldTone } from '@/composables/use-status-tone.js'; -import { Play, MapPin, Flower, Network, RotateCw } from 'lucide-vue-next'; +import { Play, MapPin, Flower, Network, RotateCw, FileText, ChevronDown } from 'lucide-vue-next'; import { Icon } from '@iconify/vue'; import FitText from '@/components/widgets/FitText.vue'; +import CopyButton from '@/components/widgets/CopyButton.vue'; import { INLINE_TIERS } from '@/composables/use-fit-text.js'; const { t } = useI18n(); const store = useMainStore(); const lang = computed(() => store.lang); +const userPreferences = computed(() => store.userPreferences); +const isSimpleMode = computed(() => userPreferences.value.simpleMode); const { dotClass, textClass } = useStatusTone(); const isStarted = ref(false); const IPArray = ref([]); const stunServers = reactive([ - { id: 'google', name: 'Google', url: 'stun.l.google.com:19302', ip: t('webrtc.StatusWait'), natType: t('webrtc.StatusWait'), country: t('webrtc.StatusWait'), country_code: '' }, - { id: 'blackberry', name: 'BlackBerry', url: 'stun.voip.blackberry.com:3478', ip: t('webrtc.StatusWait'), natType: t('webrtc.StatusWait'), country: t('webrtc.StatusWait'), country_code: '' }, - { id: 'twilio', name: 'Twilio', url: 'global.stun.twilio.com', ip: t('webrtc.StatusWait'), natType: t('webrtc.StatusWait'), country: t('webrtc.StatusWait'), country_code: '' }, - { id: 'cloudflare', name: 'Cloudflare', url: 'stun.cloudflare.com', ip: t('webrtc.StatusWait'), natType: t('webrtc.StatusWait'), country: t('webrtc.StatusWait'), country_code: '' }, + { id: 'google', name: 'Google', url: 'stun.l.google.com:19302', ip: t('webrtc.StatusWait'), natType: t('webrtc.StatusWait'), country: t('webrtc.StatusWait'), country_code: '', sdpLog: [], sdpOpen: false }, + { id: 'blackberry', name: 'BlackBerry', url: 'stun.voip.blackberry.com:3478', ip: t('webrtc.StatusWait'), natType: t('webrtc.StatusWait'), country: t('webrtc.StatusWait'), country_code: '', sdpLog: [], sdpOpen: false }, + { id: 'twilio', name: 'Twilio', url: 'global.stun.twilio.com', ip: t('webrtc.StatusWait'), natType: t('webrtc.StatusWait'), country: t('webrtc.StatusWait'), country_code: '', sdpLog: [], sdpOpen: false }, + { id: 'cloudflare', name: 'Cloudflare', url: 'stun.cloudflare.com', ip: t('webrtc.StatusWait'), natType: t('webrtc.StatusWait'), country: t('webrtc.StatusWait'), country_code: '', sdpLog: [], sdpOpen: false }, ]); // Regex extracting the IP portion out of an ICE candidate line @@ -154,6 +181,18 @@ const checkSTUNServer = (stun) => { let timer = null; let settled = false; + // Event log helper: every line is prefixed with a millisecond offset + // from the start of this server's test, so the log reads as a timeline. + // `stun.sdpLog` is reset to a fresh reactive array (rather than length=0) + // so the panel scroll position resets cleanly between runs. + const startTime = performance.now(); + stun.sdpLog = []; + stun.sdpOpen = false; + const log = (msg) => { + const ts = Math.round(performance.now() - startTime).toString().padStart(4, ' '); + stun.sdpLog.push(`[+${ts}ms] ${msg}`); + }; + const finish = () => { settled = true; if (timer !== null) { @@ -174,6 +213,7 @@ const checkSTUNServer = (stun) => { stun.natType = label; stun.country = label; stun.country_code = ''; + log(`status: ${statusKey}`); finish(); }; @@ -188,6 +228,7 @@ const checkSTUNServer = (stun) => { } stun.ip = ip; stun.natType = determineNATType(candidate); + log(`resolved IP: ${ip}`); IPArray.value = [...IPArray.value, ip]; // fetchCountryCode swallows its own errors and returns null on miss, // so a single null check handles both "no MaxMind source" and @@ -207,12 +248,30 @@ const checkSTUNServer = (stun) => { }; try { + log(`new RTCPeerConnection -> stun:${stun.url}`); pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:' + stun.url }] }); activeConnections.add(pc); + pc.onicegatheringstatechange = () => { + if (pc) log(`iceGatheringState: ${pc.iceGatheringState}`); + }; + + // `RTCIceCandidate` errors fire when STUN/TURN signalling fails + // (host unreachable, auth issues). They don't terminate the + // connection on their own, but they're highly informative for + // debugging "why did this STUN never answer" cases. + pc.onicecandidateerror = (event) => { + log(`iceCandidateError: ${event.errorCode || ''} ${event.errorText || ''} url=${event.url || ''}`.trim()); + }; + pc.onicecandidate = (event) => { - if (!event.candidate || settled) return; // null = end-of-candidates + if (!event.candidate) { + log('candidate: (end-of-candidates)'); + return; + } const candidate = event.candidate.candidate; + log(`candidate: ${candidate}`); + if (settled) return; const type = candidate.split(' ')[7]; // Only server-reflexive / peer-reflexive candidates represent a @@ -226,7 +285,16 @@ const checkSTUNServer = (stun) => { }; pc.createDataChannel(''); - pc.createOffer().then((offer) => pc.setLocalDescription(offer)); + pc.createOffer().then((offer) => { + // Offer SDP is multi-line; record it as a single block so the + // Collapsible
 renders it verbatim.
+        log(`createOffer ok\n--- Offer SDP ---\n${offer.sdp}--- end SDP ---`);
+        return pc.setLocalDescription(offer);
+      }).then(() => {
+        log('setLocalDescription ok');
+      }).catch((error) => {
+        log(`offer/setLocalDescription failed: ${error?.message || error}`);
+      });
 
       timer = setTimeout(() => {
         timer = null;
@@ -234,6 +302,7 @@ const checkSTUNServer = (stun) => {
       }, 5000);
     } catch (error) {
       console.error('STUN Server Test Error:', error);
+      log(`exception: ${error?.message || error}`);
       failWith('StatusError');
     }
   });
@@ -292,12 +361,12 @@ const checkAllWebRTC = async (isRefresh) => {
   const allSettledPromise = Promise.allSettled(promises);
   const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000));
   return Promise.race([allSettledPromise, timeoutPromise]).then(() => {
-    store.setLoadingStatus('webrtc', true);
+    store.setLoadingStatus('WebRTC', true);
   });
 };
 
 onMounted(() => {
-  store.setMountingStatus('webrtc', true);
+  store.setMountingStatus('WebRTC', true);
 });
 
 // Close any still-open peer connections if the component unmounts
diff --git a/frontend/components/advanced-tools/BrowserInfo.vue b/frontend/components/advanced-tools/BrowserInfo.vue
index 95dd47b23..f385b576f 100644
--- a/frontend/components/advanced-tools/BrowserInfo.vue
+++ b/frontend/components/advanced-tools/BrowserInfo.vue
@@ -17,9 +17,9 @@
                 

{{ errorMsg }}

- + - +
@@ -36,12 +36,9 @@ User Agent - +

{{ userAgent.ua }}

@@ -93,6 +90,36 @@ + + +
+
+ +

+ {{ t('browserinfo.components.title') }} +

+
+

{{ t('browserinfo.components.note') }}

+ +
+
+
+ {{ key }} + + {{ t(`browserinfo.options.${key}`) }} + +
+
+
+ {{ row.key }} + {{ row.value }} +
+
+
+
+
@@ -104,11 +131,11 @@ import { ref, computed, watch, onMounted } from 'vue'; import { useMainStore } from '@/store'; import { useI18n } from 'vue-i18n'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Spinner } from '@/components/ui/spinner'; -import { BriefcaseBusiness, CopyCheck, Copy, Fingerprint, Info } from 'lucide-vue-next'; +import { BriefcaseBusiness, Fingerprint, Info, Microscope } from 'lucide-vue-next'; import { Card, CardContent } from '@/components/ui/card'; +import CopyButton from '@/components/widgets/CopyButton.vue'; const { t } = useI18n(); @@ -116,55 +143,95 @@ const store = useMainStore(); const isMobile = computed(() => store.isMobile); const fingerprint = ref(''); +// Per-component data from `result.components`; powers the breakdown panel. +const components = ref({}); +// One toggle per component thumbmarkjs's factory registers — keep in sync +// when upstream adds a new one (also need `options.` in all 4 locales). const excludeOptions = ref({ 'audio': true, 'canvas': true, 'fonts': true, 'hardware': true, 'locales': true, - 'permissions': true, + 'permissions': false, 'plugins': true, 'screen': true, 'system': true, 'webgl': true, 'math': true, + 'intl': true, + 'mediaDevices': true, + 'speech': true, + 'mathml': true, + 'webrtc': true, }); const errorMsg = ref(''); const checkingStatus = ref('idle'); -const copiedStatus = ref(false); const userAgent = ref(''); const gpu = ref(''); const otherInfos = ref({}); -// Browser fields: data-driven, avoid template repetition of 8 jn-detail sections +const fmtList = (arr) => Array.isArray(arr) && arr.length ? arr.join(', ') : '—'; +const fmtDisplay = (d) => d + ? `${d.width}×${d.height} · ${d.colorDepth}-bit · ${d.pixelRatio}× DPR` + : '—'; +const fmtConnection = (c) => { + if (!c) return 'N/A'; + const parts = []; + if (c.effectiveType) parts.push(c.effectiveType); + if (c.downlink) parts.push(`${c.downlink} Mbps`); + if (typeof c.rtt === 'number') parts.push(`${c.rtt} ms RTT`); + if (c.saveData) parts.push('save-data'); + return parts.length ? parts.join(' · ') : '—'; +}; +// DNT spec: '1' enabled, '0' disabled, anything else = unset. +const fmtDNT = (v) => v === '1' ? t('browserinfo.browser.doNotTrackOn') + : v === '0' ? t('browserinfo.browser.doNotTrackOff') + : t('browserinfo.browser.doNotTrackUnset'); +const fmtPdfViewer = (b) => b === true ? t('browserinfo.browser.pdfViewerYes') + : b === false ? t('browserinfo.browser.pdfViewerNo') + : '—'; + +// Order: identity → locale → display → network → input → privacy → capabilities. const browserFields = computed(() => { if (!userAgent.value || !userAgent.value.browser) return []; + const o = otherInfos.value; return [ - { label: t('browserinfo.browser.browserName'), value: `${userAgent.value.browser.name || ''} ${userAgent.value.browser.version || ''}`.trim() }, - { label: t('browserinfo.browser.deviceVendor'), value: `${userAgent.value.device.vendor || ''} ${userAgent.value.device.model || ''}`.trim() }, - { label: t('browserinfo.browser.engineName'), value: `${userAgent.value.engine.name || ''} ${userAgent.value.engine.version || ''}`.trim() }, + { label: t('browserinfo.browser.browserName'), value: `${userAgent.value.browser.name || ''} ${userAgent.value.browser.version || ''}`.trim() }, + { label: t('browserinfo.browser.deviceVendor'), value: `${userAgent.value.device.vendor || ''} ${userAgent.value.device.model || ''}`.trim() }, + { label: t('browserinfo.browser.engineName'), value: `${userAgent.value.engine.name || ''} ${userAgent.value.engine.version || ''}`.trim() }, { label: t('browserinfo.browser.cpuArchitecture'), value: userAgent.value.device.cpu ? userAgent.value.device.cpu.architecture : 'N/A' }, - { label: t('browserinfo.browser.osName'), value: `${userAgent.value.os.name || ''} ${userAgent.value.os.version || ''}`.trim() }, - { label: t('browserinfo.browser.gpu'), value: gpu.value }, - { label: t('browserinfo.browser.language'), value: otherInfos.value.browserLanguage }, - { label: t('browserinfo.browser.cpuCores'), value: otherInfos.value.cpucores }, - { label: t('browserinfo.browser.cookieEnabled'), value: otherInfos.value.cookieEnabled + { label: t('browserinfo.browser.osName'), value: `${userAgent.value.os.name || ''} ${userAgent.value.os.version || ''}`.trim() }, + { label: t('browserinfo.browser.gpu'), value: gpu.value }, + { label: t('browserinfo.browser.languages'), value: fmtList(o.languages) }, + { label: t('browserinfo.browser.timezone'), value: o.timezone }, + { label: t('browserinfo.browser.display'), value: fmtDisplay(o.display) }, + { label: t('browserinfo.browser.connection'), value: fmtConnection(o.connection) }, + { label: t('browserinfo.browser.touchPoints'), value: typeof o.touchPoints === 'number' ? String(o.touchPoints) : '—' }, + { label: t('browserinfo.browser.cpuCores'), value: o.cpucores }, + { label: t('browserinfo.browser.doNotTrack'), value: fmtDNT(o.doNotTrack) }, + { label: t('browserinfo.browser.pdfViewer'), value: fmtPdfViewer(o.pdfViewer) }, + { label: t('browserinfo.browser.cookieEnabled'), value: o.cookieEnabled ? t('browserinfo.browser.cookieEnabledTrue') : t('browserinfo.browser.cookieEnabledFalse') }, ]; }); -const getGPU = async () => { +// Raw WebGL renderer — same string sites read via UNMASKED_RENDERER_WEBGL. +const getGPU = () => { try { - const { getGPUTier } = await import('detect-gpu'); - const gpuTier = await getGPUTier(); - gpu.value = gpuTier && gpuTier.gpu - ? gpuTier.gpu.toLowerCase().replace(/(^\w|\s\w)/g, m => m.toUpperCase()) - : 'N/A'; + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + if (!gl) { gpu.value = 'N/A'; return; } + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + const renderer = dbg + ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) + : gl.getParameter(gl.RENDERER); + gpu.value = renderer || 'N/A'; } catch (error) { console.error('Error getting GPU info:', error); - throw error; + gpu.value = 'N/A'; } }; @@ -180,17 +247,72 @@ const getUA = async () => { } }; -const getOtherBrowserInfo = async () => { +const getOtherBrowserInfo = () => { try { - otherInfos.value.browserLanguage = navigator.language; - otherInfos.value.cookieEnabled = navigator.cookieEnabled; - otherInfos.value.cpucores = navigator.hardwareConcurrency; + otherInfos.value = { + cookieEnabled: navigator.cookieEnabled, + cpucores: navigator.hardwareConcurrency, + // Full Accept-Language preference order, not just the primary. + languages: navigator.languages || [navigator.language].filter(Boolean), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + display: { + width: screen.width, + height: screen.height, + colorDepth: screen.colorDepth, + pixelRatio: window.devicePixelRatio, + }, + // Chrome / Edge / Android only — falsy elsewhere. + connection: navigator.connection + ? { + effectiveType: navigator.connection.effectiveType, + downlink: navigator.connection.downlink, + rtt: navigator.connection.rtt, + saveData: navigator.connection.saveData, + } + : null, + touchPoints: navigator.maxTouchPoints, + doNotTrack: navigator.doNotTrack, + pdfViewer: navigator.pdfViewerEnabled, + }; } catch (error) { console.error('Error getting other browser info:', error); throw error; } }; +// Flatten a nested component value into `{ key: 'a.b[0]', value }` rows so +// the breakdown panel renders one leaf per row instead of a JSON blob. +const flatten = (value, prefix = '') => { + const rows = []; + if (value === null || value === undefined) { + rows.push({ key: prefix || '·', value: String(value) }); + return rows; + } + if (typeof value !== 'object') { + rows.push({ key: prefix || '·', value: String(value) }); + return rows; + } + if (Array.isArray(value)) { + if (value.length === 0) { + rows.push({ key: prefix || '·', value: '[]' }); + } else { + value.forEach((item, i) => { + rows.push(...flatten(item, prefix ? `${prefix}[${i}]` : `[${i}]`)); + }); + } + return rows; + } + const keys = Object.keys(value); + if (keys.length === 0) { + rows.push({ key: prefix || '·', value: '{}' }); + } else { + keys.forEach(k => { + rows.push(...flatten(value[k], prefix ? `${prefix}.${k}` : k)); + }); + } + return rows; +}; + const getExcludeOptions = async () => { const results = []; const checkOptions = (options, prefix = '') => { @@ -208,11 +330,16 @@ const getExcludeOptions = async () => { const getFingerPrint = async () => { fingerprint.value = t('browserinfo.calculating'); try { - let excludes = await getExcludeOptions(); - const { getFingerprint, setOption } = await import('@thumbmarkjs/thumbmarkjs'); - setOption('exclude', excludes); - const getFP = await getFingerprint(); - fingerprint.value = getFP; + const excludes = await getExcludeOptions(); + const { Thumbmark } = await import('@thumbmarkjs/thumbmarkjs'); + // `stabilize: []` overrides the default ['private', 'iframe'] — the + // library otherwise unconditionally drops permissions (the 'iframe' + // rule has no browsers filter, fires everywhere) and silently masks + // canvas/audio/fonts on private mode. Our toggles are the only gate. + const tm = new Thumbmark({ exclude: excludes, stabilize: [] }); + const result = await tm.get(); + fingerprint.value = result.thumbmark; + components.value = result.components || {}; } catch (error) { console.error('Error getting fingerprint:', error); throw error; @@ -231,16 +358,6 @@ const getAll = async () => { } }; -const copyToClipboard = async (ua) => { - try { - await navigator.clipboard.writeText(ua); - copiedStatus.value = true; - setTimeout(() => { copiedStatus.value = false; }, 5000); - } catch (err) { - console.error('Copy error:', err); - } -}; - onMounted(() => { checkingStatus.value = 'running'; setTimeout(() => { getAll(); }, 1000); diff --git a/frontend/components/ip-infos/ASNConnectivity.vue b/frontend/components/ip-infos/ASNConnectivity.vue new file mode 100644 index 000000000..366c7ca69 --- /dev/null +++ b/frontend/components/ip-infos/ASNConnectivity.vue @@ -0,0 +1,417 @@ + + + diff --git a/frontend/components/ip-infos/ASNHistory.vue b/frontend/components/ip-infos/ASNHistory.vue index 98a251729..1677786c9 100644 --- a/frontend/components/ip-infos/ASNHistory.vue +++ b/frontend/components/ip-infos/ASNHistory.vue @@ -17,10 +17,14 @@ AS{{ row.asn }} · {{ row.org }} - - - {{ row.peers }} + + + + {{ row.peersPct }}%
@@ -62,7 +66,7 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { Badge } from '@/components/ui/badge'; -import { Building2, Clock4, RadioTower } from 'lucide-vue-next'; +import { Building2, Clock4, Eye } from 'lucide-vue-next'; const { t } = useI18n(); diff --git a/frontend/components/ip-infos/IPCard.vue b/frontend/components/ip-infos/IPCard.vue index 6ee056631..454d504d7 100644 --- a/frontend/components/ip-infos/IPCard.vue +++ b/frontend/components/ip-infos/IPCard.vue @@ -35,19 +35,15 @@ - - - +
+ :asn-history-infos="asnHistoryInfos" :asn-connectivity-infos="asnConnectivityInfos" + :configs="configs" :is-dark-mode="isDarkMode" + :enable-map="true" /> @@ -77,9 +73,8 @@ import IpDetailPanel from './IpDetailPanel.vue'; import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; +import CopyButton from '@/components/widgets/CopyButton.vue'; import { - CopyCheck, - Copy, Monitor, RotateCw, } from 'lucide-vue-next'; @@ -94,11 +89,10 @@ const props = defineProps({ isDarkMode: { type: Boolean, required: true }, isMobile: { type: Boolean, required: true }, ipGeoSource: { type: Number, required: true }, - isCardsCollapsed: { type: Boolean, required: true }, - copiedStatus: { type: Object, required: true }, configs: { type: Object, required: true }, asnInfos: { type: Object, required: true }, - asnHistoryInfos: { type: Object, default: () => ({}) } + asnHistoryInfos: { type: Object, default: () => ({}) }, + asnConnectivityInfos: { type: Object, default: () => ({}) } }); defineEmits(['refresh-card']); @@ -110,13 +104,4 @@ const hasData = computed(() => const isErrorState = computed(() => props.card.ip === t('ipInfos.IPv4Error') || props.card.ip === t('ipInfos.IPv6Error') ); - -const copyToClipboard = (ip, id) => { - navigator.clipboard.writeText(ip).then(() => { - props.copiedStatus[id] = true; - setTimeout(() => { props.copiedStatus[id] = false; }, 5000); - }).catch(err => { - console.error('Copy error', err); - }); -}; diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index 7cd32da3e..f22f38189 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -95,9 +95,15 @@ {{ t('ipInfos.isProxy') }}
- {{ data.isProxy }} · {{ data.proxyProtocol }} + {{ data.isProxy }} +
+
+ {{ data.proxyProvider }} {{ data.proxyProtocol + && + data.proxyProtocol !== 'unknown' + ? + '· ' + data.proxyProtocol : '' }}
@@ -105,10 +111,12 @@
{{ t('ipInfos.advancedData.Nativeness') }} +
- + {{ data.isNativeIP === true ? t('ipInfos.advancedData.NativeIPYes') : t('ipInfos.advancedData.NativeIPNo') }}
@@ -160,6 +168,15 @@ + + + + @@ -169,6 +186,8 @@ :asnInfos="asnInfos" /> + @@ -190,15 +209,16 @@
- - - {{ t('ipInfos.Coordinates') }} - - {{ data.longitude }}, {{ data.latitude }} + + + {{ t('ipInfos.Coordinates') }} + + {{ data.longitude }}, {{ data.latitude + }}
- Map + Map @@ -216,6 +236,10 @@ import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js'; import { toBgpPrefix } from '@/utils/bgp-prefix.js'; import ASNInfo from './ASNInfo.vue'; import ASNHistory from './ASNHistory.vue'; +// ASNConnectivity is heavy (dagre + SVG render); async-import so it +// only enters the bundle when a user opens the Connectivity panel. +import { defineAsyncComponent } from 'vue'; +const ASNConnectivity = defineAsyncComponent(() => import('./ASNConnectivity.vue')); import { JnTooltip } from '@/components/ui/tooltip'; import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; import { Progress } from '@/components/ui/progress'; @@ -225,19 +249,21 @@ import { Icon } from '@iconify/vue'; import { Earth } from 'lucide-vue-next'; import { Building2, - CircleCheck, - CircleX, CornerUpRight, + Equal, + EqualNot, EthernetPort, Gauge, History, House, Info, + Network, Lock, Map, MapPin, ShieldCheck, SignalHigh, + CircleQuestionMark, } from 'lucide-vue-next'; const { t } = useI18n(); @@ -248,6 +274,8 @@ const props = defineProps({ asnInfos: { type: Object, required: true }, // Optional — keyed by IP. IpInfos owns the shared map; QueryIP falls back to its own. asnHistoryInfos: { type: Object, default: () => ({}) }, + // Optional — keyed by numeric ASN string. Same shared-cache pattern. + asnConnectivityInfos: { type: Object, default: () => ({}) }, configs: { type: Object, required: true }, isDarkMode: { type: Boolean, required: true }, // ASNInfo requires an index; homepage cards pass their grid index, QueryIP has nothing meaningful. @@ -326,6 +354,16 @@ const openMapDialog = () => { // prefix) and as the local session-cache key. const ipPrefix = computed(() => toBgpPrefix(props.data.ip)); +// Numeric ASN (no 'AS' prefix), used as the connectivity cache key and the +// /api/asn-connectivity query param. Null when the geo source didn't return +// an ASN. +const asnNumeric = computed(() => { + const raw = props.data.asn; + if (!raw) return null; + const m = String(raw).match(/^AS?(\d+)$/i); + return m ? m[1] : null; +}); + // Toggle the panel for `name`. Clicking the already-active button collapses // the panel entirely; clicking the inactive one switches view and lazily // triggers its data fetch. Session caches (asnInfos / asnHistoryInfos) make @@ -341,6 +379,8 @@ const togglePanel = async (name) => { await getASNInfo(props.data.asn); } else if (name === 'history' && ipPrefix.value) { await getASNHistory(ipPrefix.value); + } else if (name === 'connectivity' && asnNumeric.value) { + await getASNConnectivity(asnNumeric.value); } }; @@ -369,9 +409,12 @@ const getASNHistory = async (prefix) => { trackEvent('IPCheck', 'ASNHistoryClick', 'Show ASN History'); try { if (props.asnHistoryInfos[prefix]) return; + // RIPEstat routing-history is a slow analytical endpoint (10–20s on + // cold prefixes). Backend caps it at 25s; browser waits 26s so the + // server's 504 surfaces instead of the browser aborting first. const response = await fetchWithTimeout( `/api/asn-history?prefix=${encodeURIComponent(prefix)}`, - { timeoutMs: 10000 } + { timeoutMs: 26000 } ); if (!response.ok) { props.asnHistoryInfos[prefix] = { error: true }; @@ -384,4 +427,24 @@ const getASNHistory = async (prefix) => { props.asnHistoryInfos[prefix] = { error: true }; } }; + +const getASNConnectivity = async (asn) => { + trackEvent('IPCheck', 'ASNConnectivityClick', 'Show ASN Connectivity'); + try { + if (props.asnConnectivityInfos[asn]) return; + const response = await fetchWithTimeout( + `/api/asn-connectivity?asn=${encodeURIComponent(asn)}`, + { timeoutMs: 5000 } // backend is sub-ms local lookup; tight cap is fine + ); + if (!response.ok) { + props.asnConnectivityInfos[asn] = { error: true }; + return; + } + const graph = await response.json(); + props.asnConnectivityInfos[asn] = { graph }; + } catch (error) { + console.error('Error fetching ASN connectivity:', error); + props.asnConnectivityInfos[asn] = { error: true }; + } +}; diff --git a/frontend/components/widgets/CopyButton.vue b/frontend/components/widgets/CopyButton.vue new file mode 100644 index 000000000..7cda6892a --- /dev/null +++ b/frontend/components/widgets/CopyButton.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/components/widgets/PWA.vue b/frontend/components/widgets/PWA.vue index af9d47b19..7871ed014 100644 --- a/frontend/components/widgets/PWA.vue +++ b/frontend/components/widgets/PWA.vue @@ -40,6 +40,12 @@ const popupCount = () => { return currentCount; } +const visitCount = () => { + let count = localStorage.getItem('pwaVisitCount') || 0; + count = parseInt(count, 10); + return count; +} + const showPWA = () => { const pwaInstall = document.getElementsByTagName('pwa-install')[0]; if (!pwaInstall) return; @@ -74,7 +80,14 @@ onMounted(() => { } }); - if (popupCount() < 2) { + // Track how many times the user has loaded the app. We skip the prompt + // on the very first visit so a brand-new user isn't nagged immediately; + // from the second visit onward we fall back to the original popupCount + // gate (max 2 prompts ever). + const currentVisits = visitCount() + 1; + localStorage.setItem('pwaVisitCount', currentVisits); + + if (currentVisits >= 2 && popupCount() < 2) { setTimeout(() => { showPWA(); }, 30000); diff --git a/frontend/components/widgets/Preferences.vue b/frontend/components/widgets/Preferences.vue index d75ac158c..0210a9bb2 100644 --- a/frontend/components/widgets/Preferences.vue +++ b/frontend/components/widgets/Preferences.vue @@ -2,7 +2,7 @@ + class="flex flex-col p-0 gap-0 w-full max-w-full md:w-[420px] md:max-w-[420px]">

@@ -106,7 +106,7 @@ :model-value="userPreferences.connectivityMultipleTests" @update:model-value="prefConnectivityMultipleTests" /> - @@ -149,7 +149,6 @@ const { t } = useI18n(); const store = useMainStore(); const isDarkMode = computed(() => store.isDarkMode); -const isMobile = computed(() => store.isMobile); const configs = computed(() => store.configs); const userPreferences = computed(() => store.userPreferences); const ipDBs = computed(() => store.ipDBs); diff --git a/frontend/components/widgets/QueryIP.vue b/frontend/components/widgets/QueryIP.vue index 1f5f1519e..1fa9a8baf 100644 --- a/frontend/components/widgets/QueryIP.vue +++ b/frontend/components/widgets/QueryIP.vue @@ -46,8 +46,8 @@ + :asn-history-infos="asnHistoryInfos" :asn-connectivity-infos="asnConnectivityInfos" + :configs="configs" :is-dark-mode="isDarkMode" :enable-map="false" /> @@ -92,6 +92,7 @@ const isChecking = ref('idle'); const ipGeoSource = ref(userPreferences.value.ipGeoSource); const asnInfos = ref({}); const asnHistoryInfos = ref({}); +const asnConnectivityInfos = ref({}); watch(() => userPreferences.value.ipGeoSource, (newVal) => { ipGeoSource.value = newVal; diff --git a/frontend/composables/use-refresh-orchestrator.js b/frontend/composables/use-refresh-orchestrator.js index 526027e4f..f731192b7 100644 --- a/frontend/composables/use-refresh-orchestrator.js +++ b/frontend/composables/use-refresh-orchestrator.js @@ -35,10 +35,10 @@ export function useRefreshOrchestrator({ refs, store, t, userPreferences, infoMa }; const refreshEverything = () => { - store.setLoadingStatus('connectivity', false); - store.setLoadingStatus('webrtc', false); - store.setLoadingStatus('dnsleaktest', false); - store.setLoadingStatus('ipcheck', false); + store.setLoadingStatus('Connectivity', false); + store.setLoadingStatus('WebRTC', false); + store.setLoadingStatus('DNSLeakTest', false); + store.setLoadingStatus('IPInfo', false); const { IPCheckRef, connectivityRef, webRTCRef, dnsLeaksRef } = refs; scheduleTimedTasks([ @@ -62,9 +62,9 @@ export function useRefreshOrchestrator({ refs, store, t, userPreferences, infoMa setTimeout(() => webRTCRef.value.checkAllWebRTC(false), t3); setTimeout(() => dnsLeaksRef.value.checkAllDNSLeakTest(false), t4); } else { - store.setLoadingStatus('connectivity', true); - store.setLoadingStatus('webrtc', true); - store.setLoadingStatus('dnsleaktest', true); + store.setLoadingStatus('Connectivity', true); + store.setLoadingStatus('WebRTC', true); + store.setLoadingStatus('DNSLeakTest', true); } } else { setTimeout(() => loadingControl(t1, t2, t3, t4), 1000); diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 223f047d0..43b53744b 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1093,7 +1093,7 @@ }, { "version": "v6.2.0", - "date": "May 12, 2026", + "date": "May 13, 2026", "content": [ { "type": "add", @@ -1123,5 +1123,56 @@ } } ] + }, + { + "version": "v6.3.0", + "date": "Beta", + "content": [ + { + "type": "add", + "change": { + "en": "Now you can view the upstream connectivity graph of an ASN", + "zh": "可以查看 ASN 上游连接图,更直观地了解 ASN 的网络拓扑结构", + "fr": "Vous pouvez maintenant voir le graphique de connectivité des ASN", + "tr": "Bir ASN'in üst bağlantı grafiğini görüntüleyebilirsiniz" + } + }, + { + "type": "add", + "change": { + "en": "Added simple mode, hide the description text of each module", + "zh": "添加简约模式,隐藏每个模块的说明文字", + "fr": "Ajout du mode simple, masque le texte de description de chaque module", + "tr": "Basit mod eklendi, her modülün açıklamasını gizler" + } + }, + { + "type": "improve", + "change": { + "en": "Added SDP Log to WebRTC Test tool", + "zh": "WebRTC 测试工具添加了 SDP 日志功能", + "fr": "Ajout de la fonctionnalité de journalisation SDP dans l'outil de test WebRTC", + "tr": "WebRTC Test aracına SDP günlüğü eklendi" + } + }, + { + "type": "improve", + "change": { + "en": "Improved Browser Info tool for more accurate and practical information", + "zh": "优化了浏览器信息工具,使其更加准确和实用", + "fr": "Amélioration de l'outil d'informations sur le navigateur pour plus d'informations précises et pratiques", + "tr": "Tarayıcı bilgisi aracı iyileştirildi, daha doğru ve kullanışlı bilgiler" + } + }, + { + "type": "fix", + "change": { + "en": "Fixed some small issues", + "zh": "修复了一些小问题", + "fr": "Correction de petits problèmes", + "tr": "Birkaç küçük sorun düzeltildi" + } + } + ] } ] \ No newline at end of file diff --git a/frontend/data/default-preferences.js b/frontend/data/default-preferences.js index ced79b608..54c9ab9e6 100644 --- a/frontend/data/default-preferences.js +++ b/frontend/data/default-preferences.js @@ -10,7 +10,7 @@ export const DEFAULT_PREFERENCES = Object.freeze({ autoStart: true, hideUnavailableIPStack: false, popupConnectivityNotifications: true, - ipCardsToShow: 4, + ipCardsToShow: 2, ipGeoSource: 0, lang: 'auto', // auto | zh | en | fr | tr // User-defined extra targets for the Connectivity test grid. Each entry: diff --git a/frontend/data/sections.js b/frontend/data/sections.js index 728aa6445..33eca3b4a 100644 --- a/frontend/data/sections.js +++ b/frontend/data/sections.js @@ -18,37 +18,27 @@ export const SECTION_IDS = [ export const DEFAULT_SECTION = 'IPInfo'; -// store.mountingStatus key mapping (lowercase snake-ish format, compatible with historical naming) -const MOUNTING_KEYS = [ - 'ipcheck', - 'connectivity', - 'webrtc', - 'dnsleaktest', - 'speedtest', - 'advancedtools', -]; - -// store.loadingStatus key mapping (subset of mounting: without speedtest / advancedtools) -const LOADING_KEYS = [ - 'ipcheck', - 'connectivity', - 'webrtc', - 'dnsleaktest', +// Loading semantics only apply to the four sections that actually run async +// network tests on mount. SpeedTest and AdvancedTools mount but have no +// orchestrator-tracked loading phase. Listed explicitly (not derived via +// slice) so future reorders of SECTION_IDS don't silently change membership. +const LOADING_SECTIONS = [ + 'IPInfo', + 'Connectivity', + 'WebRTC', + 'DNSLeakTest', ]; function createStatusObject(keys) { - return keys.reduce((acc, key) => { - acc[key] = false; - return acc; - }, {}); + return Object.fromEntries(keys.map((key) => [key, false])); } /** Returns a fresh mountingStatus initial object */ export function createMountingStatus() { - return createStatusObject(MOUNTING_KEYS); + return createStatusObject(SECTION_IDS); } /** Returns a fresh loadingStatus initial object */ export function createLoadingStatus() { - return createStatusObject(LOADING_KEYS); + return createStatusObject(LOADING_SECTIONS); } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 870df0fcc..009f91046 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -141,7 +141,7 @@ "Navigation": "Navigation", "IPInfo": "IP Infos", "Connectivity": "Connectivity", - "WebRTC": "WebRTC Test", + "WebRTC": "WebRTC Leak Test", "DNSLeakTest": "DNS Leak Test", "SpeedTest": "Speed Test", "PingTest": "Global Latency", @@ -161,7 +161,7 @@ "autoRun": "Auto Run", "autoRunTips": "If disabled, the app will only check the local IP and will not run tests automatically.", "simpleMode": "Simple Mode", - "simpleModeTips": "Reduces the information displayed on IP cards. Effective on mobile devices only.", + "simpleModeTips": "Hide the description text of each module, suitable for users who are already familiar with the application.", "connectivityMultipleTests": "Multiple Connectivity Refreshes", "connectivityMultipleTestsTips": "When enabled, the app runs multiple checks at startup and displays the lowest latency.", "popupConnectivityNotifications": "Pop-up Connectivity Alerts", @@ -228,7 +228,18 @@ "cpuArchitecture": "CPU Architecture", "gpu": "GPU", "cpuCores": "CPU Cores", - "language": "Language Code", + "languages": "Languages", + "timezone": "Timezone", + "display": "Display", + "connection": "Connection", + "touchPoints": "Touch Points", + "doNotTrack": "Do Not Track", + "doNotTrackOn": "On", + "doNotTrackOff": "Off", + "doNotTrackUnset": "Not Set", + "pdfViewer": "PDF Viewer", + "pdfViewerYes": "Yes", + "pdfViewerNo": "No", "cookieEnabled": "Cookies Enabled", "cookieEnabledTrue": "Yes", "cookieEnabledFalse": "No" @@ -250,7 +261,16 @@ "screen": "Screen", "system": "Browser Version", "webgl": "WebGL", - "math": "Math" + "math": "Math", + "intl": "Intl Formatters", + "mediaDevices": "Media Devices", + "speech": "Speech Synthesis", + "mathml": "MathML", + "webrtc": "WebRTC" + }, + "components": { + "title": "Fingerprint Components", + "note": "Each component below contributes to the hash above. Toggle items in the Fingerprint panel to exclude them from the fingerprint." }, "calError": "Failed to calculate browser information, please refresh and try again.", "calculating": "Calculating..." @@ -496,13 +516,15 @@ "Tooltips": { "RefreshIPCard": "Refresh IP Card", "RefreshConnectivityTests": "Refresh Connectivity Tests", - "RefreshWebRTC": "Refresh WebRTC Test", + "RefreshWebRTC": "Refresh WebRTC Leak Test", "RefreshDNSLeakTest": "Refresh DNS Leak Test", "SpeedTestButton": "Start/Pause Speed Test", "SourceSelect": "Select IP Geolocation Source", "ShowASNInfo": "Show AS Details", "ShowASNHistory": "Show ASN History", + "ShowASNConnectivity": "Show ASN Connectivity", "CopyIP": "Copy IP Address", + "CopySdpLog": "Copy SDP Log", "ViewOnMap": "View location on map", "InfoMask": "Hide IP Information", "QueryIP": "Query IP Address", @@ -556,12 +578,25 @@ "note": "Historical ASN announcements for this IP: ", "empty": "No historical routing data found for this IP.", "error": "Failed to load ASN history.", - "seenBy": "Seen by {peers} BGP full-feed peers" + "seenBy": "RIPE RIS visibility: {pct}% — peak {peers} full-feed peers" + }, + "ASNConnectivity": { + "note": "Upstream paths from this AS toward the Tier 1 global transit providers: ", + "empty": "No upstream connectivity data available for this AS.", + "error": "Failed to load ASN connectivity.", + "expand": "Expand", + "dialogTitle": "Network connectivity for AS{asn}", + "legendOrigin": "Origin", + "legendTier1": "Tier 1 ISP", + "legendIntermediate": "Intermediate AS", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "zoomReset": "Reset zoom" }, "advancedData": { - "proxyYes": "Must be a proxy or VPN", - "proxyMaybe": "Possibly a proxy or VPN", - "proxyNo": "Not a proxy or VPN", + "proxyYes": "Flagged as proxy / VPN", + "proxyMaybe": "Possible proxy / VPN", + "proxyNo": "Not flagged", "proxyUnknown": "Unknown type", "proxyUnknownProtocol": "Unknown protocol", "type": { @@ -571,9 +606,10 @@ "Hosting": "Hosting", "unknownType": "Unknown Type" }, - "Nativeness": "Nativeness", - "NativeIPYes": "It's a native IP", - "NativeIPNo": "Not a native IP" + "Nativeness": "ASN-Geo match", + "NativeIPYes": "Aligned", + "NativeIPNo": "Mismatched", + "nativenessTooltip": "Whether the ASN's registered country matches the IP's geolocation country" } }, "connectivity": { @@ -606,10 +642,11 @@ }, "webrtc": { "id": "webrtc", - "Title": "WebRTC Test", + "Title": "WebRTC Leak Test", "Note": "WebRTC often establishes connections directly via UDP. If the test returns your real IP, it means your proxy settings do not cover these connections. In addition to detecting the IP you use when connecting to WebRTC, we also detect your NAT type. However, NAT type detection is not 100% accurate and is for reference only.", "StatusWait": "Awaiting Test", "StatusError": "Connection Error", + "SdpLog": "SDP Log", "NATType": { "srflx": "Port Restricted Cone or Symmetric", "prflx": "Port Restricted Cone", @@ -636,7 +673,7 @@ "speedtest": { "id": "speedtest", "Title": "Speed Test", - "Note": "Speed test will be done via Cloudflare's edge network, which may take a long time. If your network is located in mainland China, the data may be biased. The speed test results are for reference only, the actual speed will be faster. You can choose the packet size for download and upload tests. By default, the speed test will perform 4 rounds of packet uploads and downloads, please be mindful of data usage.", + "Note": "The speed test will be conducted using Cloudflare's edge network. You can choose the packet size for download and upload according to your needs. During the test, packets will be uploaded and downloaded 4 times — please be mindful of data usage. Since Cloudflare does not deploy edge nodes in every country worldwide, the results are for reference only; actual speeds may be faster.", "Download": "Download", "Upload": "Upload", "Latency": "Latency", @@ -644,6 +681,7 @@ "Unit": "Mbps", "StatusWait": "--", "StatusError": "Test Error", + "loadedLatency": "Loaded latency: ", "videoStreaming": "Video Streaming: ", "gaming": ", Gaming: ", "rtc": ", Video Chatting: ", @@ -653,9 +691,11 @@ "connectionTo": " to test server:", "connectionEnd": " .", "quality": { - "Good": "Good", - "Medium": "Medium", - "Bad": "Bad" + "bad": "Bad", + "poor": "Poor", + "average": "Average", + "good": "Good", + "great": "Great" } }, "pingtest": { @@ -717,7 +757,7 @@ "RefreshEverything": "Refresh Everything", "RefreshIPCard": "Refresh IP Card", "RefreshConnectivityTests": "Refresh Connectivity Tests", - "RefreshWebRTC": "Refresh WebRTC Test", + "RefreshWebRTC": "Refresh WebRTC Leak Test", "RefreshDNSLeakTest": "Refresh DNS Leak Test", "SpeedTestButton": "Start/Pause Speed Test", "PingTest": "Open Global Latency Test panel", @@ -745,9 +785,9 @@ "SecurityChecklist": "Open Security Checklist panel" }, "page": { - "title": "IPCheck.ing - Check My IP Address and Geolocation - Check WebRTC Connection IP - DNS Leak Test - Speed Test - Jason Ng Open Source", - "description": "A better and open source IP Toolbox. Easy to check what's your IPs, IP information, check for DNS leaks, examine WebRTC connections, test website availability, lookup DNS record, lookup Whois, and test latency from around the world.", - "keywords": "MyIP, IP tool, IP check, IP lookup, DNS leak test, WebRTC test, speed test, DNS lookup, WHOIS lookup, ping test, privacy test", + "title": "IPCheck.ing - Check My IP Address and Geolocation - IP Leak Test - DNS Leak Test - IP Quality Check - Test Network Speed - Jason Ng Open Source", + "description": "A better and open-source IP toolbox: check your IP address & geolocation, test IP for WebRTC and DNS IP leaks, run an IP quality check, browser fingerprint check, plus speed test, global latency test, MTR test, Whois search, and more.", + "keywords": "MyIP, IP tool, IP check, IP Leak Check, IP quality check, IP lookup, DNS leak test, WebRTC leak test, browser fingerprint check, speed test, global latency test, MTR test, DNS lookup, Whois search", "footerLink": "https://github.com/jason5ng32/MyIP", "copyRightName": "Originally from ", "copyRightLink": "https://ipcheck.ing", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 88d045c0b..d2f85b949 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -141,7 +141,7 @@ "Navigation": "Navigation", "IPInfo": "Infos IP", "Connectivity": "Connectivité", - "WebRTC": "Test WebRTC", + "WebRTC": "Test WebRTC Leak", "DNSLeakTest": "Test de fuite DNS", "SpeedTest": "Test de vitesse", "PingTest": "Latence mondiale", @@ -161,7 +161,7 @@ "autoRun": "Exécution Automatique", "autoRunTips": "Si désactivé, l'appli vérifiera seulement l'IP local et n'exécutera pas de tests automatiquement.", "simpleMode": "Mode Simple", - "simpleModeTips": "Réduit les informations affichées sur les cartes IP. Effectif uniquement sur les appareils mobiles.", + "simpleModeTips": "Masque le texte de description de chaque module. Effectif uniquement sur les appareils mobiles.", "connectivityMultipleTests": "Rafraîchissements Multiples de Connectivité", "connectivityMultipleTestsTips": "Activé, l'appli effectue plusieurs vérifications au démarrage et affiche le délai le plus bas.", "popupConnectivityNotifications": "Alertes de Connectivité Pop-up", @@ -228,7 +228,18 @@ "cpuArchitecture": "Architecture CPU", "gpu": "GPU", "cpuCores": "Cœurs CPU", - "language": "Code Langue", + "languages": "Langues Préférées", + "timezone": "Fuseau Horaire", + "display": "Affichage", + "connection": "Connexion Réseau", + "touchPoints": "Points Tactiles", + "doNotTrack": "Ne Pas Suivre", + "doNotTrackOn": "Activé", + "doNotTrackOff": "Désactivé", + "doNotTrackUnset": "Non Défini", + "pdfViewer": "Lecteur PDF", + "pdfViewerYes": "Oui", + "pdfViewerNo": "Non", "cookieEnabled": "Cookies Activés", "cookieEnabledTrue": "Oui", "cookieEnabledFalse": "Non" @@ -250,7 +261,16 @@ "screen": "Écran", "system": "Version du Navigateur", "webgl": "WebGL", - "math": "Mathématiques" + "math": "Mathématiques", + "intl": "Formats Intl", + "mediaDevices": "Périphériques Média", + "speech": "Synthèse Vocale", + "mathml": "MathML", + "webrtc": "WebRTC" + }, + "components": { + "title": "Composants de l'empreinte", + "note": "Chaque composant ci-dessous contribue au hash affiché plus haut. Désactivez un élément dans le panneau Fingerprint pour l'exclure de l'empreinte." }, "calError": "Échec du calcul des informations du navigateur, veuillez rafraîchir et réessayer.", "calculating": "Calcul en cours..." @@ -496,13 +516,15 @@ "Tooltips": { "RefreshIPCard": "Actualiser la carte IP", "RefreshConnectivityTests": "Actualiser les tests de connectivité", - "RefreshWebRTC": "Actualiser le test WebRTC", + "RefreshWebRTC": "Actualiser le test WebRTC Leak", "RefreshDNSLeakTest": "Actualiser le test de fuite DNS", "SpeedTestButton": "Démarrer/Pause le test de vitesse", "SourceSelect": "Sélectionner la source de géolocalisation IP", "ShowASNInfo": "Afficher les détails AS", "ShowASNHistory": "Afficher l'historique ASN", + "ShowASNConnectivity": "Afficher la connectivité ASN", "CopyIP": "Copier l'adresse IP", + "CopySdpLog": "Copier le journal SDP", "ViewOnMap": "Voir l'emplacement sur la carte", "InfoMask": "Masquer les informations IP", "QueryIP": "Interroger l'adresse IP", @@ -556,12 +578,25 @@ "note": "Annonces ASN historiques pour cette IP : ", "empty": "Aucune donnée de routage historique trouvée pour cette IP.", "error": "Échec du chargement de l'historique ASN.", - "seenBy": "Vu par {peers} pairs BGP full-feed" + "seenBy": "Visibilité RIPE RIS: {pct}% — pic {peers} pairs full-feed" + }, + "ASNConnectivity": { + "note": "Chemins amont depuis cet AS vers les opérateurs de transit Tier 1 mondiaux: ", + "empty": "Aucune donnée de connectivité amont disponible pour cet AS.", + "error": "Échec du chargement de la connectivité ASN.", + "expand": "Agrandir", + "dialogTitle": "Carte de connectivité réseau d'AS{asn}", + "legendOrigin": "Origine", + "legendTier1": "FAI Tier 1", + "legendIntermediate": "AS intermédiaire", + "zoomIn": "Zoom avant", + "zoomOut": "Zoom arrière", + "zoomReset": "Réinitialiser le zoom" }, "advancedData": { - "proxyYes": "Doit être un proxy ou un VPN", - "proxyMaybe": "Possiblement un proxy ou un VPN", - "proxyNo": "Pas un proxy ou un VPN", + "proxyYes": "Signalé comme proxy / VPN", + "proxyMaybe": "Probablement proxy / VPN", + "proxyNo": "Non signalé", "proxyUnknown": "Type inconnu", "proxyUnknownProtocol": "Protocole inconnu", "type": { @@ -571,9 +606,10 @@ "Hosting": "Hébergement", "unknownType": "Type inconnu" }, - "Nativeness": "Nativité", - "NativeIPYes": "C'est un IP natif", - "NativeIPNo": "Ce n'est pas un IP natif" + "Nativeness": "Correspondance ASN-Geo", + "NativeIPYes": "Aligné", + "NativeIPNo": "Décalé", + "nativenessTooltip": "Indique si le pays d'enregistrement de l'ASN correspond au pays de géolocalisation de l'IP" } }, "connectivity": { @@ -606,10 +642,11 @@ }, "webrtc": { "id": "webrtc", - "Title": "Test WebRTC", + "Title": "Test WebRTC Leak", "Note": "WebRTC établit souvent des connexions directes via UDP. Si le test renvoie votre véritable adresse IP, cela signifie que vos paramètres de proxy ne couvrent pas ces connexions. En plus de détecter l'adresse IP que vous utilisez lors de la connexion à WebRTC, nous détectons également votre type de NAT. Cependant, la détection du type de NAT n'est pas précise à 100% et est fournie à titre indicatif uniquement.", "StatusWait": "En attente du test", "StatusError": "Erreur de connexion", + "SdpLog": "Journal SDP", "NATType": { "srflx": "Port Restricted Cone ou Symmetric", "prflx": "Port Restricted Cone", @@ -636,7 +673,7 @@ "speedtest": { "id": "speedtest", "Title": "Test de vitesse", - "Note": "Le test de vitesse sera effectué via le réseau de bordure de Cloudflare, ce qui peut prendre beaucoup de temps. Si votre réseau est situé en Chine continentale, les données peuvent être biaisées. Les résultats du test de vitesse sont donnés à titre indicatif, la vitesse réelle sera plus rapide. Vous pouvez choisir la taille des paquets pour les tests de téléchargement et d'upload. Par défaut, le test de vitesse effectuera 4 tours de téléchargements et d'uploads de paquets, veuillez faire attention à la consommation de données.", + "Note": "Le test de vitesse sera effectué via le réseau périphérique de Cloudflare. Vous pouvez choisir la taille des paquets de téléchargement et de téléversement selon vos besoins. Pendant le test, les paquets seront téléchargés et téléversés 4 fois — veuillez tenir compte de votre consommation de données. Cloudflare ne disposant pas de nœuds périphériques dans tous les pays, les résultats sont donnés à titre indicatif ; la vitesse réelle peut être plus élevée.", "Download": "Téléchargement", "Upload": "Téléversement", "Latency": "Latence", @@ -644,6 +681,7 @@ "Unit": "Mbps", "StatusWait": "--", "StatusError": "Erreur de test", + "loadedLatency": "Latence en charge : ", "videoStreaming": "Streaming vidéo : ", "gaming": ", Jeu : ", "rtc": ", Chat vidéo : ", @@ -653,9 +691,11 @@ "connectionTo": " vers le serveur de test:", "connectionEnd": " .", "quality": { - "Good": "Bon", - "Medium": "Moyen", - "Bad": "Mauvais" + "bad": "Mauvais", + "poor": "Faible", + "average": "Moyen", + "good": "Bon", + "great": "Excellent" } }, "pingtest": { @@ -717,7 +757,7 @@ "RefreshEverything": "Actualiser tout", "RefreshIPCard": "Actualiser la carte IP", "RefreshConnectivityTests": "Actualiser les tests de connectivité", - "RefreshWebRTC": "Actualiser le test WebRTC", + "RefreshWebRTC": "Actualiser le test WebRTC Leak", "RefreshDNSLeakTest": "Actualiser le test de fuite DNS", "SpeedTestButton": "Démarrer/Pause le test de vitesse", "PingTest": "Ouvrir le Test de latence mondial", @@ -745,9 +785,9 @@ "SecurityChecklist": "Ouvrir la Liste de sécurité" }, "page": { - "title": "IPCheck.ing - Vérifier mon adresse IP et géolocalisation - Vérifier l'adresse IP de connexion WebRTC - Test de fuite DNS - Test de vitesse - Jason Ng Open Source", - "description": "Une boîte à outils IP meilleure et open source. Facile à vérifier vos adresses IP, informations IP, vérifier les fuites DNS, examiner les connexions WebRTC, tester la disponibilité des sites web, rechercher des enregistrements DNS, rechercher des informations Whois et tester la latence dans le monde entier.", - "keywords": "MonIP, Outil IP, Mon IP, Vérification IP, Recherche IP, Test de fuite DNS, Test WebRTC, Test de vitesse, Recherche DNS, Recherche Whois, Test de ping, Test de confidentialité", + "title": "IPCheck.ing - Vérifier mon adresse IP et géolocalisation - Test de fuite IP - Test de fuite DNS - Vérification de la qualité IP - Test de vitesse réseau - Jason Ng Open Source", + "description": "Une boîte à outils IP open source et tout-en-un : vérifiez votre adresse IP et sa géolocalisation, testez les fuites IP via WebRTC et DNS, effectuez une vérification de la qualité IP, une vérification de l'empreinte du navigateur, ainsi qu'un test de vitesse, un test de latence mondiale, un test MTR, une recherche Whois, et plus encore.", + "keywords": "MonIP, Outil IP, Vérification IP, Test de fuite IP, Test de fuite DNS, Qualité IP, Test de fuite WebRTC, Empreinte du navigateur, Test de vitesse, Test de latence mondiale, Test MTR, Recherche DNS, Recherche Whois", "footerLink": "https://github.com/jason5ng32/MyIP", "copyRightName": "Originalement de ", "copyRightLink": "https://ipcheck.ing", diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json index c3417c5ca..3989b5434 100644 --- a/frontend/locales/tr.json +++ b/frontend/locales/tr.json @@ -141,7 +141,7 @@ "Navigation": "Navigasyon", "IPInfo": "IP Bilgileri", "Connectivity": "Bağlantı", - "WebRTC": "WebRTC Testi", + "WebRTC": "WebRTC Sızıntı Testi", "DNSLeakTest": "DNS Sızıntı Testi", "SpeedTest": "Hız Testi", "PingTest": "Global Gecikme", @@ -161,7 +161,7 @@ "autoRun": "Otomatik Çalıştır", "autoRunTips": "Devre dışı bırakılırsa, uygulama yalnızca yerel IP'yi kontrol eder ve testleri otomatik olarak çalıştırmaz.", "simpleMode": "Basit Mod", - "simpleModeTips": "IP kartlarında görüntülenen bilgiyi azaltır. Yalnızca mobil cihazlarda etkilidir.", + "simpleModeTips": "Her modülün açıklamasını gizler. Yalnızca mobil cihazlarda etkilidir.", "connectivityMultipleTests": "Çoklu Bağlantı Yenilemeleri", "connectivityMultipleTestsTips": "Etkinleştirildiğinde, uygulama başlangıçta birden fazla kontrol çalıştırır ve en düşük gecikmeyi görüntüler.", "popupConnectivityNotifications": "Açılır Bağlantı Uyarıları", @@ -228,7 +228,18 @@ "cpuArchitecture": "CPU Mimarisi", "gpu": "GPU", "cpuCores": "CPU Çekirdekleri", - "language": "Dil Kodu", + "languages": "Dil Tercihleri", + "timezone": "Saat Dilimi", + "display": "Ekran", + "connection": "Ağ Bağlantısı", + "touchPoints": "Dokunma Noktaları", + "doNotTrack": "İzlemeyi Engelle", + "doNotTrackOn": "Açık", + "doNotTrackOff": "Kapalı", + "doNotTrackUnset": "Ayarlanmamış", + "pdfViewer": "PDF Görüntüleyici", + "pdfViewerYes": "Evet", + "pdfViewerNo": "Hayır", "cookieEnabled": "Çerezler Etkin", "cookieEnabledTrue": "Evet", "cookieEnabledFalse": "Hayır" @@ -250,7 +261,16 @@ "screen": "Ekran", "system": "Tarayıcı Sürümü", "webgl": "WebGL", - "math": "Matematik" + "math": "Matematik", + "intl": "Intl Biçimlendiricileri", + "mediaDevices": "Medya Cihazları", + "speech": "Konuşma Sentezi", + "mathml": "MathML", + "webrtc": "WebRTC" + }, + "components": { + "title": "Parmak İzi Bileşenleri", + "note": "Aşağıdaki her bileşen yukarıdaki hash'e katkıda bulunur. Fingerprint panelindeki ilgili anahtarı kapatarak bir bileşeni parmak izinden çıkarabilirsiniz." }, "calError": "Tarayıcı bilgileri hesaplanamadı, lütfen yenileyip tekrar deneyin.", "calculating": "Hesaplanıyor..." @@ -495,13 +515,15 @@ "Tooltips": { "RefreshIPCard": "IP Kartını Yenile", "RefreshConnectivityTests": "Bağlantı Testlerini Yenile", - "RefreshWebRTC": "WebRTC Testini Yenile", + "RefreshWebRTC": "WebRTC Sızıntı Testini Yenile", "RefreshDNSLeakTest": "DNS Sızıntı Testini Yenile", "SpeedTestButton": "Hız Testini Başlat/Duraklat", "SourceSelect": "IP Coğrafi Konum Kaynağını Seç", "ShowASNInfo": "AS Detaylarını Göster", "ShowASNHistory": "ASN Geçmişini Göster", + "ShowASNConnectivity": "ASN Bağlantı Haritasını Göster", "CopyIP": "IP Adresini Kopyala", + "CopySdpLog": "SDP Günlüğünü Kopyala", "ViewOnMap": "Konumu haritada görüntüle", "InfoMask": "IP Bilgilerini Gizle", "QueryIP": "IP Adresini Sorgula", @@ -555,12 +577,25 @@ "note": "Bu IP için geçmiş ASN duyuruları : ", "empty": "Bu IP için geçmiş yönlendirme verisi bulunamadı.", "error": "ASN geçmişi yüklenemedi.", - "seenBy": "{peers} BGP full-feed eşi tarafından görüldü" + "seenBy": "RIPE RIS görünürlüğü: %{pct} — zirvede {peers} full-feed eş" + }, + "ASNConnectivity": { + "note": "Bu AS'tan Tier 1 küresel transit sağlayıcılarına giden yukarı akış yolları: ", + "empty": "Bu AS için yukarı akış bağlantı verisi bulunamadı.", + "error": "ASN bağlantı haritası yüklenemedi.", + "expand": "Büyüt", + "dialogTitle": "AS{asn} ağ bağlantı haritası", + "legendOrigin": "Kaynak", + "legendTier1": "Tier 1 ISP", + "legendIntermediate": "Ara AS", + "zoomIn": "Yakınlaştır", + "zoomOut": "Uzaklaştır", + "zoomReset": "Sıfırla" }, "advancedData": { - "proxyYes": "Kesinlikle bir proxy veya VPN", - "proxyMaybe": "Muhtemelen bir proxy veya VPN", - "proxyNo": "Proxy veya VPN değil", + "proxyYes": "Proxy / VPN olarak işaretlendi", + "proxyMaybe": "Olası proxy / VPN", + "proxyNo": "İşaretlenmedi", "proxyUnknown": "Bilinmeyen tür", "proxyUnknownProtocol": "Bilinmeyen protokol", "type": { @@ -570,9 +605,10 @@ "Hosting": "Barındırma", "unknownType": "Bilinmeyen Tür" }, - "Nativeness": "Yerellik", - "NativeIPYes": "Bu yerel bir IP", - "NativeIPNo": "Yerel bir IP değil" + "Nativeness": "ASN-Coğrafya eşleşmesi", + "NativeIPYes": "Eşleşiyor", + "NativeIPNo": "Eşleşmiyor", + "nativenessTooltip": "ASN'nin kayıtlı ülkesinin IP'nin coğrafi konum ülkesiyle eşleşip eşleşmediğini gösterir" } }, "connectivity": { @@ -605,10 +641,11 @@ }, "webrtc": { "id": "webrtc", - "Title": "WebRTC Testi", + "Title": "WebRTC Sızıntı Testi", "Note": "WebRTC genellikle doğrudan UDP üzerinden bağlantı kurar. Test gerçek IP'nizi döndürürse, bu, proxy ayarlarınızın bu bağlantıları kapsamadığı anlamına gelir. WebRTC'ye bağlanırken kullandığınız IP'yi tespit etmenin yanı sıra, NAT türünüzü de tespit ediyoruz. Ancak, NAT türü tespiti %100 doğru değildir ve yalnızca referans amaçlıdır.", "StatusWait": "Test Bekleniyor", "StatusError": "Bağlantı Hatası", + "SdpLog": "SDP Günlüğü", "NATType": { "srflx": "Port Kısıtlı Koni veya Simetrik", "prflx": "Port Kısıtlı Koni", @@ -635,7 +672,7 @@ "speedtest": { "id": "speedtest", "Title": "Hız Testi", - "Note": "Ağınız anakara Çin'de bulunuyorsa, veriler yanlı olabilir. Hız testi sonuçları yalnızca referans amaçlıdır, gerçek hız daha hızlı olacaktır. İndirme ve yükleme testleri için paket boyutunu seçebilirsiniz. Varsayılan olarak, hız testi 4 tur paket yüklemesi ve indirmesi yapacaktır, lütfen veri kullanımına dikkat edin.", + "Note": "Hız testi, Cloudflare'in kenar ağı kullanılarak gerçekleştirilecektir. İndirme ve yükleme için veri paketi boyutunu ihtiyacınıza göre seçebilirsiniz. Test sırasında veri paketleri 4 kez yüklenip indirilecektir; lütfen veri tüketimine dikkat edin. Cloudflare dünyadaki her ülkede kenar düğümü bulundurmadığından, test sonuçları yalnızca referans niteliğindedir ve gerçek hız daha yüksek olabilir.", "Download": "İndirme", "Upload": "Yükleme", "Latency": "Gecikme", @@ -643,6 +680,7 @@ "Unit": "Mbps", "StatusWait": "--", "StatusError": "Test Hatası", + "loadedLatency": "Yüklü gecikme: ", "videoStreaming": "Video Akışı: ", "gaming": ", Oyun: ", "rtc": ", Görüntülü Sohbet: ", @@ -652,9 +690,11 @@ "connectionTo": " test sunucusuna:", "connectionEnd": " .", "quality": { - "Good": "İyi", - "Medium": "Orta", - "Bad": "Kötü" + "bad": "Kötü", + "poor": "Zayıf", + "average": "Orta", + "good": "İyi", + "great": "Mükemmel" } }, "pingtest": { @@ -716,7 +756,7 @@ "RefreshEverything": "Her Şeyi Yenile", "RefreshIPCard": "IP Kartını Yenile", "RefreshConnectivityTests": "Bağlantı Testlerini Yenile", - "RefreshWebRTC": "WebRTC Testini Yenile", + "RefreshWebRTC": "WebRTC Sızıntı Testini Yenile", "RefreshDNSLeakTest": "DNS Sızıntı Testini Yenile", "SpeedTestButton": "Hız Testini Başlat/Duraklat", "PingTest": "Global Gecikme Testi panelini aç", @@ -744,9 +784,9 @@ "SecurityChecklist": "Güvenlik Kontrol Listesi panelini aç" }, "page": { - "title": "IPCheck.ing - IP Adresimi ve Coğrafi Konumumu Kontrol Et - WebRTC Bağlantı IP'sini Kontrol Et - DNS Sızıntı Testi - Hız Testi - Jason Ng Açık Kaynak", - "description": "Daha iyi ve açık kaynaklı bir IP Araç Kutusu. IP'lerinizin ne olduğunu, IP bilgilerini, DNS sızıntılarını kontrol etmek, WebRTC bağlantılarını incelemek, web sitesi kullanılabilirliğini test etmek, DNS kaydını aramak, Whois'i aramak ve dünyanın dört bir yanından gecikmeyi test etmek kolaydır.", - "keywords": "IPim, IP Aracı, IP Adresim, IP kontrol, IP arama, DNS sızıntı testi, WebRTC testi, Hız testi, DNS arama, Whois arama, Ping testi, gizlilik testi", + "title": "IPCheck.ing - IP Adresimi ve Coğrafi Konumumu Kontrol Et - IP Sızıntı Testi - DNS Sızıntı Testi - IP Kalite Kontrolü - Ağ Hızı Testi - Jason Ng Açık Kaynak", + "description": "Daha iyi ve açık kaynaklı, hepsi bir arada IP araç kutusu: IP adresinizi ve coğrafi konumunuzu kontrol edin, WebRTC ve DNS üzerinden IP sızıntılarını test edin, IP kalite kontrolü ve tarayıcı parmak izi kontrolü yapın; ayrıca hız testi, küresel gecikme testi, MTR testi, Whois araması ve daha fazlası.", + "keywords": "IPim, IP Aracı, IP Sorgulama, IP Sızıntı Testi, DNS Sızıntı Testi, IP Kalite Testi, WebRTC Sızıntı Testi, Tarayıcı Parmak İzi Testi, Hız Testi, Küresel Gecikme Testi, MTR Testi, DNS Sorgulama, Whois Araması", "footerLink": "https://github.com/jason5ng32/MyIP", "copyRightName": "Orijinal Kaynak ", "copyRightLink": "https://ipcheck.ing", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index eb500e84c..bd52c04cb 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -141,7 +141,7 @@ "Navigation": "导航", "IPInfo": "IP 信息", "Connectivity": "网络连通性", - "WebRTC": "WebRTC 测试", + "WebRTC": "WebRTC 泄露测试", "DNSLeakTest": "DNS 泄漏测试", "SpeedTest": "网速测试", "PingTest": "全球延迟", @@ -161,7 +161,7 @@ "autoRun": "自动运行", "autoRunTips": "关闭后,打开应用将只会进行本机 IP 检测,不会自动运行其它的测试。", "simpleMode": "简洁模式", - "simpleModeTips": "减少 IP 卡片显示的内容。仅在手机上生效。", + "simpleModeTips": "隐藏每个模块的说明文字,适合已经了解本应用的用户。", "connectivityMultipleTests": "多次刷新可用性检测", "connectivityMultipleTestsTips": "开启多次刷新后,程序在启动时将运行多次检测,并显示最小延迟值。", "popupConnectivityNotifications": "显示可用性检测气泡", @@ -228,7 +228,18 @@ "cpuArchitecture": "CPU 架构", "gpu": "GPU", "cpuCores": "CPU 核心数", - "language": "语言代码", + "languages": "语言偏好", + "timezone": "时区", + "display": "显示规格", + "connection": "网络连接", + "touchPoints": "触控点数", + "doNotTrack": "请勿跟踪", + "doNotTrackOn": "已开启", + "doNotTrackOff": "已关闭", + "doNotTrackUnset": "未设置", + "pdfViewer": "PDF 阅读器", + "pdfViewerYes": "支持", + "pdfViewerNo": "不支持", "cookieEnabled": "是否启用 Cookie", "cookieEnabledTrue": "是", "cookieEnabledFalse": "否" @@ -250,7 +261,16 @@ "screen": "屏幕", "system": "浏览器版本", "webgl": "WebGL", - "math": "运算" + "math": "运算", + "intl": "国际化格式", + "mediaDevices": "媒体设备", + "speech": "语音合成", + "mathml": "MathML", + "webrtc": "WebRTC" + }, + "components": { + "title": "指纹组件明细", + "note": "下方每一项都参与了上方哈希的计算。在上方 Fingerprint 区域里关掉对应开关即可将其从指纹中排除。" }, "calError": "浏览器信息计算失败,请刷新重试。", "calculating": "计算中..." @@ -498,13 +518,15 @@ "Tooltips": { "RefreshIPCard": "刷新 IP 信息", "RefreshConnectivityTests": "刷新连通性测试", - "RefreshWebRTC": "刷新 WebRTC 测试", + "RefreshWebRTC": "刷新 WebRTC 泄露测试", "RefreshDNSLeakTest": "刷新 DNS 泄漏测试", "SpeedTestButton": "开始/暂停网速测试", "SourceSelect": "选择 IP 归属地数据来源", "ShowASNInfo": "显示 AS 详细信息", "ShowASNHistory": "显示 ASN 历史", + "ShowASNConnectivity": "显示 ASN 连接图", "CopyIP": "复制 IP 地址", + "CopySdpLog": "复制 SDP 日志", "ViewOnMap": "在地图上查看位置", "InfoMask": "隐藏信息", "QueryIP": "查询 IP 信息", @@ -558,12 +580,25 @@ "note": "该 IP 的历史 ASN 公告:", "empty": "未找到该 IP 的历史路由数据。", "error": "加载 ASN 历史失败。", - "seenBy": "被 {peers} 个 BGP 全表节点观测到" + "seenBy": "RIPE RIS 可见度:{pct}% — 峰值 {peers} 个全表 peer" + }, + "ASNConnectivity": { + "note": "该 AS 到全球 Tier 1 骨干网的上游路径:", + "empty": "未找到该 AS 的上游连接数据。", + "error": "加载 ASN 连接图失败。", + "expand": "放大", + "dialogTitle": "AS{asn} 的网络连接图", + "legendOrigin": "起源", + "legendTier1": "Tier 1 骨干", + "legendIntermediate": "中间 AS", + "zoomIn": "放大", + "zoomOut": "缩小", + "zoomReset": "重置缩放" }, "advancedData": { - "proxyYes": "是代理或 VPN", - "proxyMaybe": "可能是代理或 VPN", - "proxyNo": "不是代理或 VPN", + "proxyYes": "已识别为代理 / VPN", + "proxyMaybe": "疑似代理 / VPN", + "proxyNo": "未识别为代理", "proxyUnknown": "未知类型", "proxyUnknownProtocol": "未知协议", "type": { @@ -574,8 +609,9 @@ "unknownType": "未知类型" }, "Nativeness": "原生性", - "NativeIPYes": "是原生 IP", - "NativeIPNo": "不是原生 IP" + "NativeIPYes": "原生", + "NativeIPNo": "非原生", + "nativenessTooltip": "判断 ASN 的注册国家是否与 IP 的归属国家一致" } }, "connectivity": { @@ -608,10 +644,11 @@ }, "webrtc": { "id": "webrtc", - "Title": "WebRTC 测试", + "Title": "WebRTC 泄露测试", "Note": "WebRTC 往往通过 UDP 直连进行建立,如果测试返回了真实 IP,则意味着你的代理设置没有覆盖这些连接。除了检测你连接 WebRTC 时所使用的 IP,我们还会检测你的 NAT 类型。然而,NAT 类型的检测并不是 100% 准确的,仅供参考。", "StatusWait": "待检测", "StatusError": "连接错误", + "SdpLog": "SDP 日志", "NATType": { "srflx": "端口限制型或对称型", "prflx": "端口限制型", @@ -638,7 +675,7 @@ "speedtest": { "id": "speedtest", "Title": "网速测试", - "Note": "将从 Cloudflare 使用边沿网络进行速度测试,时间可能较长。如果你的网络位于中国大陆,数据可能会有较大的偏差。测速结果仅供参考,实际速度会更快。你可以选择下载和上传的包大小进行测试。默认情况下,测速会进行 4 次数据包上传与下载,请注意流量消耗。", + "Note": "将从 Cloudflare 使用边缘网络进行速度测试。你可以根据需要,选择测速时下载和上传的数据包大小。测速过程中,会进行 4 次数据包上传与下载,请注意流量消耗。考虑到 Cloudflare 并非在全球每一个国家都部署边缘节点,测速结果仅供参考,实际速度可能更快。", "Download": "下载", "Upload": "上传", "Latency": "延迟", @@ -646,6 +683,7 @@ "Unit": "Mbps", "StatusWait": "--", "StatusError": "测试出错", + "loadedLatency": "满载延迟:", "videoStreaming": "在线视频:", "gaming": " ,在线游戏:", "rtc": " ,视频会议:", @@ -655,9 +693,11 @@ "connectionTo": "与测速服务器:", "connectionEnd": " 的测试。", "quality": { - "Good": "优秀", - "Medium": "中等", - "Bad": "较差" + "bad": "差", + "poor": "较差", + "average": "一般", + "good": "良好", + "great": "优秀" } }, "pingtest": { @@ -719,7 +759,7 @@ "RefreshEverything": "刷新所有数据", "RefreshIPCard": "刷新某张 IP 卡片", "RefreshConnectivityTests": "刷新连通性测试", - "RefreshWebRTC": "刷新 WebRTC 测试", + "RefreshWebRTC": "刷新 WebRTC 泄露测试", "RefreshDNSLeakTest": "刷新 DNS 泄漏测试", "SpeedTestButton": "开始/暂停网速测试", "PingTest": "打开全球延迟测试面板", @@ -747,9 +787,9 @@ "SecurityChecklist": "打开安全检查清单" }, "page": { - "title": "IPCheck.ing - 查看我的 IP 地址 - 查询本机 IP 地址及归属地 - 查看 WebRTC 连接 IP - DNS 泄露检测 - 网速测试 - Jason Ng 阿禅开源作品", - "description": "好用和开源的全能 IP 工具箱。轻松查看你的 IP 地址,IP 信息,检查 DNS 泄露,检查 WebRTC 连接,测试网站可用性,查找 DNS 记录,查找 Whois 信息,测试全球延迟测试等。", - "keywords": "我的IP,IP工具,我的IP,IP查询,DNS泄漏测试,WebRTC测试,网速测试,DNS查询,Whois查询, Ping测试, 隐私测试", + "title": "IPCheck.ing - 查看我的 IP 地址及归属地 - IP 泄露检测 - DNS 泄露检测 - IP 质量检查 - 网速测试 - Jason Ng 阿禅开源作品", + "description": "好用和开源的全能 IP 工具箱。轻松查看你的 IP 地址和信息、检查 IP 泄露、DNS 与 WebRTC 泄露检查、查看 IP 质量分数、浏览器指纹检查。还有网速测试、全球延迟测试、MTR 测试、Whois 查询等。", + "keywords": "我的IP,IP工具,IP查询,IP泄露检测,DNS泄露检测,IP质量检测,WebRTC泄露检测,浏览器指纹检测,网速测试,全球延迟测试,MTR测试,DNS查询,Whois搜索", "footerLink": "https://github.com/jason5ng32/MyIP", "copyRightName": "Originally from ", "copyRightLink": "https://ipcheck.ing", diff --git a/frontend/utils/transform-ip-data.js b/frontend/utils/transform-ip-data.js index 5b4d318f4..21e47f727 100644 --- a/frontend/utils/transform-ip-data.js +++ b/frontend/utils/transform-ip-data.js @@ -34,11 +34,11 @@ function extractAdvancedData(advancedData = {}, t) { const isProxy = determineIsProxy(advancedData, t); const type = determineType(advancedData, t); const qualityScore = advancedData.score === 'sign_in_required' ? 'sign_in_required' : advancedData.score; - const proxyProtocol = determineProtocol(advancedData, t); - const proxyOperator = advancedData.proxyProvider || ""; + const proxyProtocol = advancedData.proxyProtocol || ""; + const proxyProvider = advancedData.proxyProvider || ""; const isNativeIP = advancedData.tags === 'sign_in_required' ? 'sign_in_required' : advancedData.tags.isNative; - return { isProxy, type, qualityScore, proxyProtocol, proxyOperator, isNativeIP }; + return { isProxy, type, qualityScore, proxyProtocol, proxyProvider, isNativeIP }; } // Determine if it is a proxy @@ -59,6 +59,9 @@ function determineIsProxy(advancedData, t) { // Determine proxy type function determineType(advancedData, t) { + if (advancedData.operatorType === 'sign_in_required') { + return 'sign_in_required'; + } switch (advancedData.operatorType) { case 'Business': return t('ipInfos.advancedData.type.Business'); @@ -68,21 +71,8 @@ function determineType(advancedData, t) { return t('ipInfos.advancedData.type.Wireless'); case 'Hosting': return t('ipInfos.advancedData.type.Hosting'); - case 'VPN': - if (advancedData.proxyProtocol === 'unknown') { - return t('ipInfos.advancedData.type.Hosting'); - } default: - return advancedData.operatorType || t('ipInfos.advancedData.type.unknownType'); - } -} - -// Determine proxy protocol -function determineProtocol(advancedData, t) { - if (advancedData.proxyProtocol === 'unknown' || !advancedData.proxyProtocol) { - return t('ipInfos.advancedData.proxyUnknownProtocol'); - } else { - return advancedData.proxyProtocol; + return t('ipInfos.advancedData.type.unknownType'); } } diff --git a/index.html b/index.html index d913b0118..1d7bb7053 100644 --- a/index.html +++ b/index.html @@ -4,29 +4,67 @@ + content="MyIP, IP tool, IP check, IP Leak Check, IP quality check, IP lookup, DNS leak test, WebRTC leak test, browser fingerprint check, speed test, global latency test, MTR test, DNS lookup, Whois search" /> + content="A better and open-source IP toolbox: check your IP address & geolocation, test IP for WebRTC and DNS IP leaks, run an IP quality check, browser fingerprint check, plus speed test, global latency test, MTR test, Whois search, and more."> + + + + - IPCheck.ing - Check My IP Address and Geolocation - Check WebRTC Connection IP - DNS Leak Test - Speed Test - - Ping/MTR Test - Jason Ng Open Source + IPCheck.ing - Check My IP Address and Geolocation - IP Leak Test - DNS Leak Test - IP Quality Check - Test Network Speed - Jason Ng Open Source + + + + + + + + + + + + + + + + + + + + + + + Reads the user's stored theme preference from localStorage (key + `userPreferences`, field `theme`, values: "auto" / "light" / "dark"), + falling back to the OS `prefers-color-scheme` when on "auto" or when + there's no stored preference yet. Runs synchronously — must come before + the inline