From c6b5c9463c3cd0b8295b9f5ef5027d0e6af0f356 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 13 May 2026 10:50:04 +0800 Subject: [PATCH 01/36] Feat(browser-info): expand reveal surface + drop detect-gpu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces more of what websites actually see about the user's browser: languages array, timezone, display specs, network connection class (Chrome/Edge only), touch points, Do Not Track header, PDF viewer support. Adds a structured "Fingerprint Components" breakdown panel beneath the existing card that flattens thumbmarkjs's per-component data (videocard, canvas hash, font list, etc.) into a scrollable key=value table, turning the opaque fingerprint hash into a readable info-leak inventory. Migrates thumbmarkjs to the v1.7.5+ class-based API (Thumbmark.get()) and disables its default stabilize: ['private', 'iframe'] rules — the 'iframe' rule unconditionally drops permissions even outside iframes, which made our toggle have no effect. Adds the missing mathml/webrtc component toggles to keep the list in sync with upstream's pipeline. Replaces detect-gpu (1.0 MB on disk, ~5 kB in bundle) with 10 lines reading WEBGL_debug_renderer_info directly — we never used its tier classification anyway. i18n: rename language→languages (data shape changed singular→array); add timezone/display/connection/touchPoints/doNotTrack/pdfViewer field labels + DNT and PDF value enums; add components.title/note + mathml/webrtc option labels. All four locales (en/zh/fr/tr) kept in sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/advanced-tools/BrowserInfo.vue | 187 +++++++++++++++--- frontend/locales/en.json | 24 ++- frontend/locales/fr.json | 24 ++- frontend/locales/tr.json | 24 ++- frontend/locales/zh.json | 24 ++- package.json | 1 - vite.config.js | 2 +- 7 files changed, 248 insertions(+), 38 deletions(-) diff --git a/frontend/components/advanced-tools/BrowserInfo.vue b/frontend/components/advanced-tools/BrowserInfo.vue index 95dd47b23..39dd26c5c 100644 --- a/frontend/components/advanced-tools/BrowserInfo.vue +++ b/frontend/components/advanced-tools/BrowserInfo.vue @@ -17,9 +17,9 @@

{{ errorMsg }}

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

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

+
+

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

+ +
+
+
+ {{ key }} + + {{ t(`browserinfo.options.${key}`) }} + +
+
+
+ {{ row.key }} + {{ row.value }} +
+
+
+
+
@@ -107,7 +137,7 @@ 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, CopyCheck, Copy, Fingerprint, Info, Microscope } from 'lucide-vue-next'; import { Card, CardContent } from '@/components/ui/card'; const { t } = useI18n(); @@ -116,6 +146,10 @@ 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, @@ -128,6 +162,11 @@ const excludeOptions = ref({ 'system': true, 'webgl': true, 'math': true, + 'intl': true, + 'mediaDevices': true, + 'speech': true, + 'mathml': true, + 'webrtc': true, }); const errorMsg = ref(''); const checkingStatus = ref('idle'); @@ -137,34 +176,66 @@ 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 +251,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 +334,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; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 870df0fcc..17fa078d8 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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..." diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 88d045c0b..2436cf315 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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..." diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json index c3417c5ca..d8069aba6 100644 --- a/frontend/locales/tr.json +++ b/frontend/locales/tr.json @@ -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..." diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index eb500e84c..13d7edec4 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -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": "计算中..." diff --git a/package.json b/package.json index 86b7bc1cb..8ae68e927 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "clsx": "^2.1.1", "concurrently": "^9.2.1", "country-code-lookup": "^0.1.5", - "detect-gpu": "^5.0.70", "dotenv": "^17.4.2", "express": "^5.2.1", "express-rate-limit": "^8.5.1", diff --git a/vite.config.js b/vite.config.js index 902cd7a65..6b3e41590 100644 --- a/vite.config.js +++ b/vite.config.js @@ -13,7 +13,7 @@ const nodeModuleChunkGroups = { chart: ['chart.js'], speedtest: ['@cloudflare/speedtest'], svgmap: ['svgmap'], - 'browser-detect': ['@thumbmarkjs/thumbmarkjs', 'detect-gpu', 'ua-parser-js'], + 'browser-detect': ['@thumbmarkjs/thumbmarkjs', 'ua-parser-js'], }; const sourceChunkGroups = { From 5712b776bc33f02be33c6ff55888f57b6bcadd9d Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 13 May 2026 11:35:50 +0800 Subject: [PATCH 02/36] Improvements --- .../components/advanced-tools/BrowserInfo.vue | 2 +- frontend/data/changelog.json | 17 ++++++++++++++++- package.json | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/components/advanced-tools/BrowserInfo.vue b/frontend/components/advanced-tools/BrowserInfo.vue index 39dd26c5c..c57e4f56a 100644 --- a/frontend/components/advanced-tools/BrowserInfo.vue +++ b/frontend/components/advanced-tools/BrowserInfo.vue @@ -156,7 +156,7 @@ const excludeOptions = ref({ 'fonts': true, 'hardware': true, 'locales': true, - 'permissions': true, + 'permissions': false, 'plugins': true, 'screen': true, 'system': true, diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index 223f047d0..1299602b9 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,20 @@ } } ] + }, + { + "version": "v6.3.0", + "date": "Beta", + "content": [ + { + "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" + } + } + ] } ] \ No newline at end of file diff --git a/package.json b/package.json index 8ae68e927..7ce775e46 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "myip", "private": true, - "version": "6.2.0", + "version": "6.3.0", "type": "module", "scripts": { "dev": "concurrently \"vite\" \"nodemon backend-server.js\"", From 64d5d299aa654ee2bde4b01c28184ba821ce237b Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 13 May 2026 12:11:43 +0800 Subject: [PATCH 03/36] Fix(cf-radar): tolerate sparse Cloudflare Radar responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some ASNs return partial or empty data from Cloudflare Radar — small networks lack traffic summaries (no IPv4/IPv6 split, no device stats), and private-range ASNs (RFC 6996, AS64512–AS65534) have no info at all. The handler's cleanUpResponseData blindly destructured `data.asnInfo.result.asn.name` etc., throwing TypeError on missing fields and surfacing as 500 to the frontend. Add optional chaining throughout cleanUpResponseData so missing fields fall through as undefined. The existing filterData (which strips 'NaN%' from parseFloat(undefined)) finishes the cleanup downstream. ASNInfo.vue already handles partial responses — the affected fields just don't render. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/cf-radar.js | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) 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 }; } From bb0465a2204e7b9f84100862f89ab0577e62f912 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 13 May 2026 16:17:21 +0800 Subject: [PATCH 04/36] Feat(ip-info): ASN upstream connectivity graph from CAIDA data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /api/asn-connectivity returns a layered graph of upstream paths from the origin AS toward Tier 1 ISPs (bgp.tools-style). Rendered via dagre + SVG with Manhattan routing and an expand-into-dialog action. Backend is fully offline. Two new local snapshots: - common/as-org-db CAIDA as2org (ASN → org name) - common/as-rel-db CAIDA as-rel2 (p2c relationships, Tier 1 set) common/caida-updater handles bootstrap + periodic refresh for both datasets in one vendor-scoped file, mirroring maxmind-updater. New common/decompress unifies gzip / bzip2 stream decompression so future formats are one switch case rather than a new dependency per updater. Tier 1 membership is derived from CAIDA (no providers + customer cone >= 100), replacing the hand-rolled list. Origins that are themselves Tier 1 are flagged so the frontend can render a combined origin+Tier 1 box. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 12 + api/AGENTS.md | 1 + api/asn-connectivity.js | 145 +++++++ api/asn-history.js | 43 +-- backend-server.js | 20 +- common/as-org-db.js | 93 +++++ common/as-rel-db.js | 126 ++++++ common/caida-updater.js | 364 ++++++++++++++++++ common/decompress.js | 17 + common/guards.js | 16 + common/ripestat.js | 32 ++ frontend/components/IpInfos.vue | 5 +- .../components/ip-infos/ASNConnectivity.vue | 281 ++++++++++++++ frontend/components/ip-infos/IPCard.vue | 6 +- .../components/ip-infos/IpDetailPanel.vue | 50 +++ frontend/components/widgets/QueryIP.vue | 5 +- frontend/data/changelog.json | 18 + frontend/locales/en.json | 11 + frontend/locales/fr.json | 11 + frontend/locales/tr.json | 11 + frontend/locales/zh.json | 11 + package.json | 2 + 22 files changed, 1240 insertions(+), 40 deletions(-) create mode 100644 api/asn-connectivity.js create mode 100644 common/as-org-db.js create mode 100644 common/as-rel-db.js create mode 100644 common/caida-updater.js create mode 100644 common/decompress.js create mode 100644 common/ripestat.js create mode 100644 frontend/components/ip-infos/ASNConnectivity.vue diff --git a/.gitignore b/.gitignore index fb6d570fb..b898f3d43 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,18 @@ 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/ 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..06d5f6627 100644 --- a/api/asn-history.js +++ b/api/asn-history.js @@ -3,7 +3,12 @@ // 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'; +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 +20,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 +51,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 +70,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' }); @@ -106,7 +93,7 @@ export default async (req, res) => { 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) { 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..200496e89 --- /dev/null +++ b/common/ripestat.js @@ -0,0 +1,32 @@ +// 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. */ +export function fetchRoutingHistory(resource, { timeoutMs = 8000 } = {}) { + 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/components/IpInfos.vue b/frontend/components/IpInfos.vue index a14609fc5..f9f245883 100644 --- a/frontend/components/IpInfos.vue +++ b/frontend/components/IpInfos.vue @@ -20,7 +20,7 @@ + :asnConnectivityInfos="asnConnectivityInfos" @refresh-card="refreshCard" /> @@ -114,6 +114,9 @@ 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({}); diff --git a/frontend/components/ip-infos/ASNConnectivity.vue b/frontend/components/ip-infos/ASNConnectivity.vue new file mode 100644 index 000000000..7c7ea1d1a --- /dev/null +++ b/frontend/components/ip-infos/ASNConnectivity.vue @@ -0,0 +1,281 @@ + + + diff --git a/frontend/components/ip-infos/IPCard.vue b/frontend/components/ip-infos/IPCard.vue index 6ee056631..cfafa0bfd 100644 --- a/frontend/components/ip-infos/IPCard.vue +++ b/frontend/components/ip-infos/IPCard.vue @@ -46,7 +46,8 @@ @@ -98,7 +99,8 @@ const props = defineProps({ 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']); diff --git a/frontend/components/ip-infos/IpDetailPanel.vue b/frontend/components/ip-infos/IpDetailPanel.vue index 7cd32da3e..3800379ce 100644 --- a/frontend/components/ip-infos/IpDetailPanel.vue +++ b/frontend/components/ip-infos/IpDetailPanel.vue @@ -160,6 +160,15 @@ + + + + @@ -169,6 +178,8 @@ :asnInfos="asnInfos" /> + @@ -216,6 +227,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'; @@ -233,6 +248,7 @@ import { History, House, Info, + Network, Lock, Map, MapPin, @@ -248,6 +264,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 +344,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 +369,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); } }; @@ -384,4 +414,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/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/data/changelog.json b/frontend/data/changelog.json index 1299602b9..31b712580 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1128,6 +1128,15 @@ "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": "improve", "change": { @@ -1136,6 +1145,15 @@ "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" + } } ] } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 17fa078d8..18e69ed9b 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -522,6 +522,7 @@ "SourceSelect": "Select IP Geolocation Source", "ShowASNInfo": "Show AS Details", "ShowASNHistory": "Show ASN History", + "ShowASNConnectivity": "Show ASN Connectivity", "CopyIP": "Copy IP Address", "ViewOnMap": "View location on map", "InfoMask": "Hide IP Information", @@ -578,6 +579,16 @@ "error": "Failed to load ASN history.", "seenBy": "Seen by {peers} BGP full-feed peers" }, + "ASNConnectivity": { + "note": "Upstream paths from this AS toward the Tier 1 global transit providers. Built from CAIDA AS Relationships.", + "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" + }, "advancedData": { "proxyYes": "Must be a proxy or VPN", "proxyMaybe": "Possibly a proxy or VPN", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 2436cf315..4009e0b97 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -522,6 +522,7 @@ "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", "ViewOnMap": "Voir l'emplacement sur la carte", "InfoMask": "Masquer les informations IP", @@ -578,6 +579,16 @@ "error": "Échec du chargement de l'historique ASN.", "seenBy": "Vu par {peers} pairs BGP full-feed" }, + "ASNConnectivity": { + "note": "Chemins amont depuis cet AS vers les opérateurs de transit Tier 1 mondiaux. Construit à partir de CAIDA AS Relationships.", + "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" + }, "advancedData": { "proxyYes": "Doit être un proxy ou un VPN", "proxyMaybe": "Possiblement un proxy ou un VPN", diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json index d8069aba6..54bc1f38e 100644 --- a/frontend/locales/tr.json +++ b/frontend/locales/tr.json @@ -521,6 +521,7 @@ "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", "ViewOnMap": "Konumu haritada görüntüle", "InfoMask": "IP Bilgilerini Gizle", @@ -577,6 +578,16 @@ "error": "ASN geçmişi yüklenemedi.", "seenBy": "{peers} BGP full-feed eşi tarafından görüldü" }, + "ASNConnectivity": { + "note": "Bu AS'tan Tier 1 küresel transit sağlayıcılarına giden yukarı akış yolları. CAIDA AS Relationships üzerinden oluşturulmuştur.", + "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" + }, "advancedData": { "proxyYes": "Kesinlikle bir proxy veya VPN", "proxyMaybe": "Muhtemelen bir proxy veya VPN", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 13d7edec4..da37734b0 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -524,6 +524,7 @@ "SourceSelect": "选择 IP 归属地数据来源", "ShowASNInfo": "显示 AS 详细信息", "ShowASNHistory": "显示 ASN 历史", + "ShowASNConnectivity": "显示 ASN 连接图", "CopyIP": "复制 IP 地址", "ViewOnMap": "在地图上查看位置", "InfoMask": "隐藏信息", @@ -580,6 +581,16 @@ "error": "加载 ASN 历史失败。", "seenBy": "被 {peers} 个 BGP 全表节点观测到" }, + "ASNConnectivity": { + "note": "该 AS 到全球 Tier 1 骨干网的上游路径,数据来自 CAIDA AS Relationships。", + "empty": "未找到该 AS 的上游连接数据。", + "error": "加载 ASN 连接图失败。", + "expand": "放大", + "dialogTitle": "AS{asn} 的网络连接图", + "legendOrigin": "起源", + "legendTier1": "Tier 1 骨干", + "legendIntermediate": "中间 AS" + }, "advancedData": { "proxyYes": "是代理或 VPN", "proxyMaybe": "可能是代理或 VPN", diff --git a/package.json b/package.json index 7ce775e46..194b1efa2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "clsx": "^2.1.1", "concurrently": "^9.2.1", "country-code-lookup": "^0.1.5", + "dagre": "^0.8.5", "dotenv": "^17.4.2", "express": "^5.2.1", "express-rate-limit": "^8.5.1", @@ -45,6 +46,7 @@ "tar": "^7.5.15", "tw-animate-css": "^1.4.0", "ua-parser-js": "^2.0.9", + "unbzip2-stream": "^1.4.3", "vaul-vue": "^0.4.1", "vue": "^3.5.34", "vue-i18n": "^11.4.2", From d4da045c1fd350440ec5a59d4175f3b7e27d2780 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 13 May 2026 16:26:03 +0800 Subject: [PATCH 05/36] Docs(asn): document CAIDA_AUTO_UPDATE; credit RIPE NCC + CAIDA - Add CAIDA_AUTO_UPDATE row to env tables in all four README locales and to .env.example. - Add RIPE NCC + CAIDA to Footer thanks list. - Small locale + ASN Connectivity touch-ups. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 2 ++ README.md | 1 + README_FR.md | 1 + README_TR.md | 1 + README_ZH.md | 1 + frontend/components/Footer.vue | 2 ++ frontend/components/ip-infos/ASNConnectivity.vue | 1 - frontend/locales/en.json | 2 +- frontend/locales/fr.json | 2 +- frontend/locales/tr.json | 2 +- frontend/locales/zh.json | 2 +- 11 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index d68b853a7..d0e33dcc5 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,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/README.md b/README.md index 1033b7146..46ebc0059 100644 --- a/README.md +++ b/README.md @@ -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..5680bda40 100644 --- a/README_FR.md +++ b/README_FR.md @@ -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..8c6c6fb05 100644 --- a/README_TR.md +++ b/README_TR.md @@ -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..7125bcc8f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -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/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/ip-infos/ASNConnectivity.vue b/frontend/components/ip-infos/ASNConnectivity.vue index 7c7ea1d1a..aa193417d 100644 --- a/frontend/components/ip-infos/ASNConnectivity.vue +++ b/frontend/components/ip-infos/ASNConnectivity.vue @@ -3,7 +3,6 @@ origin AS toward Tier 1 ISPs. Lazy-loads dagre on first open. -->
- {{ t('ipInfos.ASNConnectivity.note') }}