diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bda661a5..c0cced780 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ jobs: check: name: Test + Build runs-on: ubuntu-latest + # Normal run is ~1 min; a hosted runner that loses its connection would + # otherwise sit "in progress" for the 6h default before failing. + timeout-minutes: 10 steps: - name: Checkout @@ -22,7 +25,7 @@ jobs: - name: Setup pnpm # Reads the `packageManager` field in package.json to pin the pnpm version, # so CI uses the exact same pnpm as local dev. - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Setup Node uses: actions/setup-node@v6 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 2f4e83c8d..e0500ffc3 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -22,6 +22,9 @@ jobs: build-and-push: runs-on: ubuntu-latest environment: production + # The multi-arch build normally lands in ~5 min; a hosted runner that loses + # its connection would otherwise sit "in progress" for the 6h default. + timeout-minutes: 20 steps: - name: Check Out Repo diff --git a/.gitignore b/.gitignore index 0914cc8d3..c74601514 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ common/as-rel-db/*.next .learnings/ docs/ .plan/ +.skills/ # Local Scripts (the listed exceptions are public — contributors need # fetch-favicons.js for the Connectivity favicon pipeline, and the i18n pair diff --git a/AGENTS.md b/AGENTS.md index 1d511db7b..9836e4cb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,26 @@ Single source of truth for anyone — human or AI — contributing to MyIP. Area-specific details: @frontend/AGENTS.md (Vue SPA) · @api/AGENTS.md (Express API). +## Local skill discovery + +Before choosing a workflow for a project task, check the repository-root +`.skills/` **directory**. It contains local skills at `.skills//SKILL.md` +and may not appear in the agent's automatically supplied skill list. + +- If the directory exists, discover its skill files with + `rg --files --hidden --no-ignore .skills -g SKILL.md` (it is gitignored). +- Read each skill's frontmatter `name` and `description`; read the full + `SKILL.md` for any skill that applies, then follow its workflow. +- Complete this discovery before choosing a fallback tool or telling the + user that a project capability or service is unavailable. Load supporting + files only when the selected skill requires them. + ## Overview **MyIP** (IPCheck.ing) is an open-source IP toolbox: IP lookup, connectivity tests, WebRTC / DNS-leak detection, speed test, MTR, Whois, security -checklist, browser fingerprint, anonymity checks, persona check, and -more. Single repo, two +checklist, browser fingerprint, anonymity checks, persona check, IP +calculator, and more. Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API. ## Stack diff --git a/README.md b/README.md index e59483d64..0a00d94c8 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Feel free to bookmark the demo or deploy your own. * 📟 **DNS Resolver**: Resolves a domain through multiple resolvers at once, grouped by country — an easy way to spot hijacking or contamination. * 📓 **Whois Search**: Performs Whois lookups for domain names and IP addresses. * 🗄️ **MAC Lookup**: Identifies the vendor and details behind a physical address. +* 🧮 **IP Calculator**: Subnet math, notation conversions and IPv6 interface details for any IP, prefix, range or list, computed locally. * 🛰️ **ASN Info & Upstream Topology**: Shows AS details, historical announcements for an IP prefix, and the upstream paths from an ASN to the Tier 1 backbone. * 📶 **Service Status**: Live availability of well-known services — Claude, OpenAI, GitHub, Cloudflare, and more — from their official status pages, with recent incidents. diff --git a/README_FR.md b/README_FR.md index 71b2125af..a786b1d71 100644 --- a/README_FR.md +++ b/README_FR.md @@ -63,6 +63,7 @@ N'hésitez pas à mettre la démo en favori ou à déployer votre propre instanc * 📟 **Résolveur DNS** : Résout un domaine via plusieurs résolveurs à la fois, regroupés par pays — un moyen simple de repérer un détournement ou une contamination. * 📓 **Recherche Whois** : Effectue des recherches Whois pour les noms de domaine et les adresses IP. * 🗄️ **Recherche MAC** : Identifie le fabricant et les détails derrière une adresse physique. +* 🧮 **Calculateur IP** : Calcul de sous-réseau, conversions de notation et détails d'interface IPv6 pour toute IP, préfixe, plage ou liste, le tout en local. * 🛰️ **Infos ASN et topologie amont** : Affiche les détails d'un AS, l'historique des annonces d'un préfixe IP et les chemins amont d'un ASN vers la dorsale Tier 1. * 📶 **État des services** : Disponibilité en direct de services connus — Claude, OpenAI, GitHub, Cloudflare et d'autres — depuis leurs pages d'état officielles, avec les incidents récents. diff --git a/README_PT-BR.md b/README_PT-BR.md index 87452f319..723d4e601 100644 --- a/README_PT-BR.md +++ b/README_PT-BR.md @@ -63,6 +63,7 @@ Adicione a demonstração aos favoritos ou faça sua própria implantação. * 📟 **Resolução DNS**: Resolve um domínio por vários resolvedores de uma só vez, agrupados por país — um jeito fácil de detectar sequestro ou contaminação. * 📓 **Pesquisa Whois**: Realiza consultas Whois para nomes de domínio e endereços IP. * 🗄️ **Consulta de MAC**: Identifica o fabricante e os detalhes por trás de um endereço físico. +* 🧮 **Calculadora de IP**: Cálculo de sub-rede, conversões de notação e detalhes de interface IPv6 para qualquer IP, prefixo, intervalo ou lista, tudo localmente. * 🛰️ **Informações de ASN e topologia de upstream**: Mostra detalhes do AS, anúncios históricos de um prefixo IP e os caminhos de upstream de um ASN até o backbone Tier 1. * 📶 **Status dos serviços**: Disponibilidade em tempo real de serviços conhecidos — Claude, OpenAI, GitHub, Cloudflare e outros — a partir de suas páginas oficiais de status, com incidentes recentes. diff --git a/README_RU.md b/README_RU.md index d2bc15657..725382ee4 100644 --- a/README_RU.md +++ b/README_RU.md @@ -63,6 +63,7 @@ * 📟 **Разрешение DNS**: разрешает домен сразу через несколько резолверов, сгруппированных по странам, — простой способ заметить перехват или загрязнение DNS. * 📓 **Поиск Whois**: выполняет Whois-запросы для доменных имён и IP-адресов. * 🗄️ **Поиск MAC-адреса**: определяет производителя и другие сведения по физическому адресу. +* 🧮 **IP-калькулятор**: расчёт подсетей, преобразование записей и сведения об интерфейсе IPv6 для любого IP, префикса, диапазона или списка — всё локально. * 🛰️ **Сведения об ASN и топология вышестоящих сетей**: показывают данные автономной системы, историю анонсов IP-префикса и пути от ASN к магистральным сетям Tier 1. * 📶 **Состояние сервисов**: актуальная доступность известных сервисов — Claude, OpenAI, GitHub, Cloudflare и других — по данным их официальных страниц состояния, включая недавние инциденты. diff --git a/README_ZH-TW.md b/README_ZH-TW.md index 8de4cfbfb..67601a3ab 100644 --- a/README_ZH-TW.md +++ b/README_ZH-TW.md @@ -63,6 +63,7 @@ * 📟 **DNS 解析**:同時透過多個解析器解析網域,並依國家分組——輕鬆看出是否存在劫持或污染。 * 📓 **Whois 查詢**:對網域名稱與 IP 位址進行 Whois 查詢。 * 🗄️ **MAC 位址查詢**:識別實體位址背後的廠商與詳細資訊。 +* 🧮 **IP 計算器**:對任意 IP、前綴、範圍或清單做子網路計算、進位轉換與 IPv6 介面解讀,全部本機完成。 * 🛰️ **ASN 資訊與上游拓撲**:顯示 AS 詳細資訊、IP 前綴的歷史宣告記錄,以及該 ASN 到 Tier 1 骨幹網路的上游路徑。 * 📶 **服務可用性**:知名服務(Claude、OpenAI、GitHub、Cloudflare 等)的即時可用狀態,資料來自它們的官方狀態頁,並附最近的事故。 diff --git a/README_ZH.md b/README_ZH.md index 66046eb78..3d18a6dee 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -63,6 +63,7 @@ * 📟 **DNS 解析**:同时通过多个解析器解析一个域名,并按国家分组 —— 轻松发现劫持或污染。 * 📓 **Whois 查询**:对域名和 IP 地址进行 Whois 查询。 * 🗄️ **MAC 地址查询**:识别一个物理地址背后的厂商与详细信息。 +* 🧮 **IP 计算器**:对任意 IP、前缀、范围或列表做子网计算、进制转换与 IPv6 接口解读,全部本地完成。 * 🛰️ **ASN 信息与上游拓扑**:展示 AS 详情、IP 前缀的历史宣告记录,以及从某个 ASN 到 Tier 1 骨干网的上游路径。 * 📶 **服务可用性**:知名服务的实时可用状态 —— Claude、OpenAI、GitHub、Cloudflare 等 —— 数据来自它们的官方状态页,并附最近的事故。 diff --git a/TRANSLATING.md b/TRANSLATING.md index dd8a0e74c..bfb7ce177 100644 --- a/TRANSLATING.md +++ b/TRANSLATING.md @@ -44,7 +44,7 @@ can see at a glance which strings you actually wrote. | `frontend/locales/.json` | The main pack — every string in the app UI (~1,150 keys) | **Yes** — all keys, values may be `""` | | `frontend/locales/privacy/.json` | Privacy policy copy (~50 keys) | No — but whole or not at all | | `frontend/locales/security-checklist/.json` | The Cybersecurity Checklist dataset (~1,080 keys, 258 items) | No — but whole or not at all | -| `frontend/data/changelog.json` | Release history, one string per language per entry (163 entries) | No — beta languages are exempt | +| `frontend/data/changelog.json` | Release history, one string per language per entry | No — beta languages are exempt | `en.json` is the reference for all of them: a translation may lag behind English, never contradict it. @@ -201,7 +201,7 @@ maintainer decision, made when the language is actually complete: - Main pack, privacy copy and security checklist all at 100% against `en` — not a single `""` left (`pnpm i18n-status` shows this). -- Changelog history back-filled — all 163 entries in `frontend/data/changelog.json`. +- Changelog history back-filled — every entry in `frontend/data/changelog.json`. - Enough of a track record that copy changes will keep landing in it. Once `status` flips to `'full'`, `tests/locale-packs.test.js` and `tests/changelog.test.js` diff --git a/api/data/dns-resolvers.js b/api/data/dns-resolvers.js index a606b84e2..dc8767b34 100644 --- a/api/data/dns-resolvers.js +++ b/api/data/dns-resolvers.js @@ -40,17 +40,18 @@ export const DNS_RESOLVERS = [ { id: 'google', name: 'Google', country: 'US', udp: '8.8.8.8', doh: 'https://dns.google/resolve?' }, { id: 'cloudflare', name: 'Cloudflare', country: 'US', udp: '1.1.1.1', doh: 'https://cloudflare-dns.com/dns-query?ct=application/dns-json&' }, { id: 'opendns', name: 'OpenDNS', country: 'US', udp: '208.67.222.222' }, - { id: 'quad9', name: 'Quad9', country: 'CH', udp: '9.9.9.9' }, { id: 'controld', name: 'ControlD', country: 'CA', udp: '76.76.2.0' }, + { id: 'dns4eu', name: 'DNS4EU', country: 'EU', udp: '86.54.11.1' }, + { id: 'dnssb', name: 'DNS.SB', country: 'EU', udp: '185.222.222.222', doh: 'https://doh.dns.sb/dns-query?' }, + { id: 'quad9', name: 'Quad9', country: 'CH', udp: '9.9.9.9' }, + { id: 'cznic', name: 'CZ.NIC ODVR', country: 'CZ', udp: '193.17.47.1' }, { id: 'adguard', name: 'AdGuard', country: 'CY', udp: '94.140.14.14', doh: 'https://dns.adguard.com/resolve?' }, { id: 'yandex', name: 'Yandex.DNS', country: 'RU', udp: '77.88.8.8' }, + { id: 'skydns', name: 'SkyDNS', country: 'RU', udp: '193.58.251.251' }, { id: 'alidns', name: 'AliDNS', country: 'CN', udp: '223.5.5.5', doh: 'https://dns.alidns.com/resolve?' }, { id: 'dnspod', name: 'DNSPod', country: 'CN', udp: '119.29.29.29' }, { id: '114dns', name: '114DNS', country: 'CN', udp: '114.114.114.114' }, { id: 'hinet', name: 'HiNet', country: 'TW', udp: '168.95.1.1' }, { id: 'giga', name: 'GIGA', country: 'TW', udp: '203.133.1.6' }, - { id: 'dns4eu', name: 'DNS4EU', country: 'EU', udp: '86.54.11.1' }, - { id: 'cznic', name: 'CZ.NIC ODVR', country: 'CZ', udp: '193.17.47.1' }, - { id: 'dnssb', name: 'DNS.SB', country: 'EU', udp: '185.222.222.222', doh: 'https://doh.dns.sb/dns-query?' }, { id: 'kt', name: 'KT', country: 'KR', udp: '168.126.63.1' }, ]; diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 43c4359fe..3eadb93b2 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -1,8 +1,7 @@ // api/dns-resolver.js — GET /api/dnsresolver: resolve a hostname against every // resolver in api/data/dns-resolvers.js (UDP DNS + DoH) in parallel and return // one flat, country-annotated result list the frontend groups by country. -import { Resolver } from 'dns'; -import { promisify } from 'util'; +import { Resolver } from 'node:dns/promises'; import { fetchUpstream } from '../common/fetch-with-timeout.js'; import logger from '../common/logger.js'; import { DNS_RESOLVERS } from './data/dns-resolvers.js'; @@ -79,46 +78,38 @@ export const dohRecords = (data, type) => { const resolveDns = async (hostname, type, name, server) => { const resolver = new Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 }); resolver.setServers([server]); - const resolve4Async = promisify(resolver.resolve4.bind(resolver)); - const resolve6Async = promisify(resolver.resolve6.bind(resolver)); - const resolveTxtAsync = promisify(resolver.resolveTxt.bind(resolver)); - const resolveCnameAsync = promisify(resolver.resolveCname.bind(resolver)); - const resolveNSAsync = promisify(resolver.resolveNs.bind(resolver)); - const resolveMXAsync = promisify(resolver.resolveMx.bind(resolver)); - const resolveSoaAsync = promisify(resolver.resolveSoa.bind(resolver)); - const resolveCaaAsync = promisify(resolver.resolveCaa.bind(resolver)); try { let addresses; // Select different parsing methods based on the type parameter switch (type) { case 'A': - addresses = await resolve4Async(hostname); + addresses = await resolver.resolve4(hostname); break; case 'AAAA': - addresses = await resolve6Async(hostname); + addresses = await resolver.resolve6(hostname); break; case 'TXT': - addresses = await resolveTxtAsync(hostname); + addresses = await resolver.resolveTxt(hostname); // TXT record parsing results is a two-dimensional array, here we flatten the result addresses = addresses.flat(); break; case 'CNAME': - addresses = await resolveCnameAsync(hostname); + addresses = await resolver.resolveCname(hostname); break; case 'NS': - addresses = await resolveNSAsync(hostname); + addresses = await resolver.resolveNs(hostname); break; case 'MX': - addresses = await resolveMXAsync(hostname); + addresses = await resolver.resolveMx(hostname); addresses = addresses.map(item => `${item.priority} ${item.exchange}.`) .join(', '); break; case 'SOA': - addresses = formatSoaRecord(await resolveSoaAsync(hostname)); + addresses = formatSoaRecord(await resolver.resolveSoa(hostname)); break; case 'CAA': - addresses = formatCaaRecords(await resolveCaaAsync(hostname)); + addresses = formatCaaRecords(await resolver.resolveCaa(hostname)); break; default: throw new Error('Unsupported type'); diff --git a/api/ipinfo-io.js b/api/ipinfo-io.js index c4f9e91bf..4e25bb636 100644 --- a/api/ipinfo-io.js +++ b/api/ipinfo-io.js @@ -2,7 +2,7 @@ // Picks a random API token (when configured) and normalizes the response // into the canonical geo shape via the shared makeGeoHandler factory. -import countryLookup from 'country-code-lookup'; +import getCountryName from '../common/country-name.js'; import { makeGeoHandler } from '../common/geo-handler.js'; function buildUrl(req) { @@ -33,8 +33,7 @@ const toCoordinate = (raw) => { export const modifyJson = (json) => { const { ip, city, region, country, loc, org } = json || {}; - // byIso returns null for an unknown or absent code. - const countryName = countryLookup.byIso(country)?.country || 'Unknown Country'; + const countryName = getCountryName(country, 'en') || 'Unknown Country'; // "37.4056,-122.0775" — a partial or non-numeric pair degrades to null. const [rawLat, rawLon] = typeof loc === 'string' ? loc.split(',') : []; diff --git a/common/country-name.js b/common/country-name.js new file mode 100644 index 000000000..50acee075 --- /dev/null +++ b/common/country-name.js @@ -0,0 +1,31 @@ +// Localized country/region names via the built-in Intl.DisplayNames API. +// Names come from the runtime's CLDR/ICU data, so any ISO 3166-1 alpha-2 code +// resolves in any supported locale with zero maintenance and zero bundle weight. + +const displayNamesCache = new Map(); + +const getDisplayNames = (lang) => { + if (!displayNamesCache.has(lang)) { + let displayNames = null; + try { + // fallback: 'none' → unknown-but-well-formed codes yield undefined + // instead of echoing the code back, so callers get '' as before + displayNames = new Intl.DisplayNames([lang], { type: 'region', fallback: 'none' }); + } catch { + // malformed / unsupported locale — cache the null so we don't retry + } + displayNamesCache.set(lang, displayNames); + } + return displayNamesCache.get(lang); +}; + +const getCountryName = (abbr, lang) => { + if (typeof abbr !== 'string' || typeof lang !== 'string') return ''; + const code = abbr.toUpperCase(); + // ZZ is the ISO "unknown region" placeholder; return '' so callers can fall back + if (!/^[A-Z]{2}$/.test(code) || code === 'ZZ') return ''; + const displayNames = getDisplayNames(lang); + return (displayNames && displayNames.of(code)) || ''; +}; + +export default getCountryName; diff --git a/common/ip-math.js b/common/ip-math.js new file mode 100644 index 000000000..da25272dd --- /dev/null +++ b/common/ip-math.js @@ -0,0 +1,374 @@ +// IP address arithmetic shared by the IP Calculator (front-end) and rdap.js's +// CIDR containment (back-end). Both families are BigInt: 32-bit `<<` goes +// negative on 240.0.0.0, and IPv6 counts exceed Number anyway. +// +// Parsed = { family: 4 | 6, value: bigint } +// Cidr = { family, prefix, address, network, aligned } +// +// Every export is pure, never throws, and returns null (false for predicates) +// on unusable input. Parsers are strict — `01.2.3.4` is rejected because +// inet_aton would read the octet as octal; ip-calc.js labels such forms +// instead. No imports, so valid-ip.js could adopt this file without a cycle. + +const BITS = { 4: 32, 6: 128 }; +const MAX = { 4: (1n << 32n) - 1n, 6: (1n << 128n) - 1n }; + +const V4_OCTET = /^(0|[1-9]\d{0,2})$/; +const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/; +const V6_CHARS = /^[0-9a-fA-F:.]+$/; + +const isFamily = (family) => family === 4 || family === 6; +const isBig = (v) => typeof v === 'bigint'; +const inFamily = (value, family) => isBig(value) && value >= 0n && value <= MAX[family]; +const isPrefix = (prefix, family) => + Number.isInteger(prefix) && prefix >= 0 && prefix <= BITS[family]; + +/* ------------------------------------------------------------------ */ +/* Parsing */ +/* ------------------------------------------------------------------ */ + +export const parseIPv4 = (str) => { + if (typeof str !== 'string') return null; + const parts = str.split('.'); + if (parts.length !== 4) return null; + let value = 0n; + for (const part of parts) { + if (!V4_OCTET.test(part)) return null; + const n = Number(part); + if (n > 255) return null; + value = (value << 8n) | BigInt(n); + } + return { family: 4, value }; +}; + +// One `::` at most, 1–4 hex per group, and a dotted-quad tail only as the +// final token (`::ffff:192.0.2.1`), which then stands in for two groups. +export const parseIPv6 = (str) => { + if (typeof str !== 'string' || str === '' || !V6_CHARS.test(str)) return null; + + let text = str; + let embeddedV4 = false; + if (str.includes('.')) { + const lastColon = str.lastIndexOf(':'); + const tail = str.slice(lastColon + 1); + if (lastColon < 0 || !tail.includes('.')) return null; + const quad = parseIPv4(tail); + if (!quad) return null; + const hi = (quad.value >> 16n).toString(16); + const lo = (quad.value & 0xffffn).toString(16); + text = `${str.slice(0, lastColon + 1)}${hi}:${lo}`; + embeddedV4 = true; + } + + const halves = text.split('::'); + if (halves.length > 2) return null; + const shorthand = halves.length === 2; + const left = halves[0] === '' ? [] : halves[0].split(':'); + const right = shorthand && halves[1] !== '' ? halves[1].split(':') : []; + const explicit = left.length + right.length; + if (shorthand ? explicit > 7 : explicit !== 8) return null; + + const groups = [...left, ...Array(shorthand ? 8 - explicit : 0).fill('0'), ...right]; + let value = 0n; + for (const group of groups) { + if (!HEX_GROUP.test(group)) return null; + value = (value << 16n) | BigInt(parseInt(group, 16)); + } + return { family: 6, value, embeddedV4 }; +}; + +export const parseIp = (str) => parseIPv4(str) || parseIPv6(str); + +export const ipToBigInt = (str) => parseIp(str)?.value ?? null; + +// `a.b.c.d/n`, `x::/n`, or (IPv4 only) `a.b.c.d/m.m.m.m` with a contiguous +// dotted mask. Host bits are kept on `address`; `network` has them cleared. +export const parseCidr = (str) => { + if (typeof str !== 'string') return null; + const slash = str.indexOf('/'); + if (slash <= 0 || slash !== str.lastIndexOf('/')) return null; + const parsed = parseIp(str.slice(0, slash)); + if (!parsed) return null; + const { family, value } = parsed; + + const rhs = str.slice(slash + 1); + let prefix = null; + if (/^\d{1,3}$/.test(rhs)) { + prefix = Number(rhs); + } else if (family === 4) { + const mask = parseIPv4(rhs); + prefix = mask ? maskToPrefix(mask.value, 4) : null; + } + if (prefix === null || !isPrefix(prefix, family)) return null; + + const network = value & prefixToMask(prefix, family); + return { family, prefix, address: value, network, aligned: network === value }; +}; + +/* ------------------------------------------------------------------ */ +/* Formatting */ +/* ------------------------------------------------------------------ */ + +export const toOctets = (value) => { + if (!inFamily(value, 4)) return null; + return [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 0xffn)); +}; + +export const toHextets = (value) => { + if (!inFamily(value, 6)) return null; + return Array.from({ length: 8 }, (_, i) => Number((value >> BigInt((7 - i) * 16)) & 0xffffn)); +}; + +export const formatIPv4 = (value) => toOctets(value)?.join('.') ?? null; + +// RFC 5952: lowercase, no leading zeros, the longest run of two or more zero +// groups becomes `::` (first run wins a tie), a lone zero group stays. +export const formatIPv6 = (value, { expanded = false } = {}) => { + const hextets = toHextets(value); + if (!hextets) return null; + if (expanded) return hextets.map((h) => h.toString(16).padStart(4, '0')).join(':'); + + let bestStart = -1; + let bestLen = 0; + for (let i = 0; i < 8; i += 1) { + if (hextets[i] !== 0) continue; + let j = i; + while (j < 8 && hextets[j] === 0) j += 1; + if (j - i > bestLen) { + bestStart = i; + bestLen = j - i; + } + i = j; + } + const hex = hextets.map((h) => h.toString(16)); + if (bestLen < 2) return hex.join(':'); + const head = hex.slice(0, bestStart).join(':'); + const tail = hex.slice(bestStart + bestLen).join(':'); + return `${head}::${tail}`; +}; + +export const formatIp = (parsed) => { + if (!parsed || !isFamily(parsed.family)) return null; + return parsed.family === 4 ? formatIPv4(parsed.value) : formatIPv6(parsed.value); +}; + +export const formatCidr = (cidr, { network = true } = {}) => { + if (!cidr || !isFamily(cidr.family)) return null; + const ip = formatIp({ family: cidr.family, value: network ? cidr.network : cidr.address }); + return ip === null ? null : `${ip}/${cidr.prefix}`; +}; + +/* ------------------------------------------------------------------ */ +/* Masks & counts */ +/* ------------------------------------------------------------------ */ + +export const prefixToMask = (prefix, family) => { + if (!isFamily(family) || !isPrefix(prefix, family)) return null; + const bits = BITS[family]; + return ((1n << BigInt(prefix)) - 1n) << BigInt(bits - prefix); +}; + +// Null unless the mask is ones followed by zeros. +export const maskToPrefix = (mask, family) => { + if (!isFamily(family) || !inFamily(mask, family)) return null; + for (let prefix = 0; prefix <= BITS[family]; prefix += 1) { + if (prefixToMask(prefix, family) === mask) return prefix; + } + return null; +}; + +export const wildcardMask = (prefix, family) => { + const mask = prefixToMask(prefix, family); + return mask === null ? null : MAX[family] ^ mask; +}; + +export const addressCount = (prefix, family) => { + if (!isFamily(family) || !isPrefix(prefix, family)) return null; + return 1n << BigInt(BITS[family] - prefix); +}; + +// IPv4 loses network + broadcast except on /31 (RFC 3021 point-to-point) +// and /32; IPv6 has no broadcast, every address is assignable. +export const usableCount = (prefix, family) => { + const count = addressCount(prefix, family); + if (count === null) return null; + if (family === 6 || prefix >= 31) return count; + return count - 2n; +}; + +// Smallest block that holds `count` addresses (number or bigint). +export const smallestPrefixFor = (count, family) => { + if (!isFamily(family)) return null; + let need; + try { + need = BigInt(count); + } catch { + return null; + } + if (need < 1n) return null; + for (let prefix = BITS[family]; prefix >= 0; prefix -= 1) { + if (addressCount(prefix, family) >= need) return prefix; + } + return null; +}; + +/* ------------------------------------------------------------------ */ +/* Block info */ +/* ------------------------------------------------------------------ */ + +export const cidrInfo = (str) => { + const cidr = parseCidr(str); + if (!cidr) return null; + const { family, prefix, address, network, aligned } = cidr; + const mask = prefixToMask(prefix, family); + const wildcard = wildcardMask(prefix, family); + const lastAddress = network | wildcard; + const hostRange = family === 4 && prefix < 31; + return { + family, + prefix, + cidr: formatCidr(cidr), + address, + network, + broadcast: family === 4 ? lastAddress : null, + first: hostRange ? network + 1n : network, + last: hostRange ? lastAddress - 1n : lastAddress, + lastAddress, + mask, + wildcard, + count: addressCount(prefix, family), + usable: usableCount(prefix, family), + aligned, + }; +}; + +/* ------------------------------------------------------------------ */ +/* Containment & ordering */ +/* ------------------------------------------------------------------ */ + +export const prefixContains = (network, prefix, family, value) => { + if (!isFamily(family) || !isPrefix(prefix, family) || !isBig(network) || !isBig(value)) return false; + const shift = BigInt(BITS[family] - prefix); + return (value >> shift) === (network >> shift); +}; + +export const cidrContains = (cidrStr, ipStr) => { + const cidr = parseCidr(cidrStr); + const ip = parseIp(ipStr); + if (!cidr || !ip || cidr.family !== ip.family) return null; + return prefixContains(cidr.network, cidr.prefix, cidr.family, ip.value); +}; + +export const cidrOverlaps = (aStr, bStr) => { + const a = parseCidr(aStr); + const b = parseCidr(bStr); + if (!a || !b || a.family !== b.family) return null; + return prefixContains(a.network, a.prefix, a.family, b.network) + || prefixContains(b.network, b.prefix, b.family, a.network); +}; + +// IPv4 sorts before IPv6; within a family, numerically. +export const compareIps = (a, b) => { + if (a.family !== b.family) return a.family < b.family ? -1 : 1; + if (a.value === b.value) return 0; + return a.value < b.value ? -1 : 1; +}; + +/* ------------------------------------------------------------------ */ +/* Set operations */ +/* ------------------------------------------------------------------ */ + +// Children of `cidrStr` at `newPrefix`. `total` is exact; `subnets` stops at +// `limit` so a /8 → /32 request never materialises 16M strings. +export const splitCidr = (cidrStr, newPrefix, { limit = 1024 } = {}) => { + const cidr = parseCidr(cidrStr); + if (!cidr || !isPrefix(newPrefix, cidr.family) || newPrefix < cidr.prefix) return null; + const { family, network } = cidr; + const total = 1n << BigInt(newPrefix - cidr.prefix); + const size = addressCount(newPrefix, family); + const emit = total < BigInt(limit) ? Number(total) : limit; + const subnets = []; + for (let i = 0n; i < BigInt(emit); i += 1n) { + subnets.push(`${formatIp({ family, value: network + i * size })}/${newPrefix}`); + } + return { family, prefix: newPrefix, total, subnets, truncated: BigInt(emit) < total }; +}; + +const isAligned = (network, prefix, family) => + (network & (addressCount(prefix, family) - 1n)) === 0n; + +// Minimal covering set per family: bare IPs become host routes, contained +// blocks are absorbed, aligned siblings merge upward (4 × /26 → one /24). +export const aggregateCidrs = (list) => { + const out = { v4: [], v6: [], invalid: [] }; + if (!Array.isArray(list)) return out; + const blocks = { 4: [], 6: [] }; + for (const token of list) { + const text = typeof token === 'string' ? token.trim() : ''; + const cidr = parseCidr(text); + if (cidr) { + blocks[cidr.family].push({ network: cidr.network, prefix: cidr.prefix }); + continue; + } + const ip = parseIp(text); + if (ip) { + blocks[ip.family].push({ network: ip.value, prefix: BITS[ip.family] }); + continue; + } + out.invalid.push(token); + } + + for (const family of [4, 6]) { + const sorted = blocks[family].sort((a, b) => { + if (a.network !== b.network) return a.network < b.network ? -1 : 1; + return a.prefix - b.prefix; + }); + const stack = []; + for (const block of sorted) { + const top = stack[stack.length - 1]; + if (top && top.prefix <= block.prefix + && prefixContains(top.network, top.prefix, family, block.network)) continue; + stack.push(block); + while (stack.length >= 2) { + const a = stack[stack.length - 2]; + const b = stack[stack.length - 1]; + const size = addressCount(a.prefix, family); + if (a.prefix === 0 || a.prefix !== b.prefix || b.network !== a.network + size + || !isAligned(a.network, a.prefix - 1, family)) break; + stack.splice(-2, 2, { network: a.network, prefix: a.prefix - 1 }); + } + } + out[family === 4 ? 'v4' : 'v6'] = stack.map( + (b) => `${formatIp({ family, value: b.network })}/${b.prefix}`, + ); + } + return out; +}; + +// Greedy cover of an inclusive range with the fewest CIDRs: at each step +// take the largest block aligned at `cur` that still fits before `end`. +export const rangeToCidrs = (startStr, endStr) => { + const start = parseIp(startStr); + const end = parseIp(endStr); + if (!start || !end || start.family !== end.family || start.value > end.value) return null; + const { family } = start; + const bits = BITS[family]; + const cidrs = []; + let cur = start.value; + while (cur <= end.value) { + let prefix = 0; + for (; prefix <= bits; prefix += 1) { + const size = addressCount(prefix, family); + if (isAligned(cur, prefix, family) && cur + size - 1n <= end.value) break; + } + cidrs.push(`${formatIp({ family, value: cur })}/${prefix}`); + cur += addressCount(prefix, family); + } + return { family, cidrs }; +}; + +export const cidrToRange = (cidrStr) => { + const info = cidrInfo(cidrStr); + if (!info) return null; + return { family: info.family, start: info.network, end: info.lastAddress }; +}; diff --git a/common/rdap.js b/common/rdap.js index d90da54b0..88162e899 100644 --- a/common/rdap.js +++ b/common/rdap.js @@ -20,7 +20,7 @@ import { fetchUpstream } from './fetch-with-timeout.js'; import { isIPv6 } from './valid-ip.js'; -import { expandIPv6 } from './bgp-prefix.js'; +import { ipToBigInt, parseCidr, prefixContains } from './ip-math.js'; import logger from './logger.js'; const BOOTSTRAP_BASE = 'https://data.iana.org/rdap/'; @@ -77,35 +77,6 @@ export async function rdapDomain(domain, { timeoutMs = 5000 } = {}) { // -- IP lookup ------------------------------------------------------------- -// Numeric value of an IP for prefix math. Returns null on junk — callers -// validate first, but bootstrap CIDR bases also pass through here. -const ipToBigInt = (ip) => { - if (typeof ip !== 'string') return null; - if (!ip.includes(':')) { - const parts = ip.split('.'); - if (parts.length !== 4) return null; - let n = 0n; - for (const p of parts) { - if (!/^\d{1,3}$/.test(p)) return null; - n = (n << 8n) | BigInt(p); - } - return n; - } - const hextets = expandIPv6(ip); - if (!hextets) return null; - let n = 0n; - for (const h of hextets) n = (n << 16n) | BigInt(parseInt(h, 16)); - return n; -}; - -const cidrContains = (cidr, ipBig, v6) => { - const [base, lenStr] = cidr.split('/'); - const baseBig = ipToBigInt(base); - if (baseBig === null) return false; - const shift = (v6 ? 128n : 32n) - BigInt(lenStr); - return (ipBig >> shift) === (baseBig >> shift); -}; - // Longest-prefix match of `ip` against an IANA ipv4/ipv6 bootstrap // `services` array (entries: [[cidr, …], [url, …]]). Exported for tests. export const findIpEndpoint = (services, ip) => { @@ -117,10 +88,11 @@ export const findIpEndpoint = (services, ip) => { let bestLen = -1; for (const [cidrs, urls] of services) { for (const cidr of cidrs) { - if (cidr.includes(':') !== v6) continue; - const len = Number(cidr.split('/')[1]); - if (len <= bestLen || !cidrContains(cidr, ipBig, v6)) continue; - bestLen = len; + const block = parseCidr(cidr); + if (!block || (block.family === 6) !== v6) continue; + if (block.prefix <= bestLen + || !prefixContains(block.network, block.prefix, block.family, ipBig)) continue; + bestLen = block.prefix; best = urls.find((u) => u.startsWith('https://')) || urls[0]; } } diff --git a/common/service-status-providers.js b/common/service-status-providers.js index 331d09bdc..ced7eb40a 100644 --- a/common/service-status-providers.js +++ b/common/service-status-providers.js @@ -33,7 +33,7 @@ export const STATUS_PROVIDERS = [ { id: 'netlify', name: 'Netlify', api: 'https://www.netlifystatus.com', page: 'https://www.netlifystatus.com' }, { id: 'render', name: 'Render', api: 'https://status.render.com', page: 'https://status.render.com' }, { id: 'supabase', name: 'Supabase', api: 'https://status.supabase.com', page: 'https://status.supabase.com' }, - { id: 'replicate', name: 'Replicate', api: 'https://replicatestatus.com', page: 'https://replicatestatus.com' }, + { id: 'discord', name: 'Discord', api: 'https://discordstatus.com', page: 'https://discordstatus.com' }, { id: 'figma', name: 'Figma', api: 'https://status.figma.com', page: 'https://status.figma.com' }, { id: 'linear', name: 'Linear', api: 'https://linearstatus.com', page: 'https://linearstatus.com' }, // Stripe's customer-facing status.stripe.com is a homegrown page with a diff --git a/common/valid-ip.js b/common/valid-ip.js index e54cd4c0c..f1627455c 100644 --- a/common/valid-ip.js +++ b/common/valid-ip.js @@ -4,9 +4,9 @@ function isValidIP(ip) { return false; } - // IPv4 + // IPv4 uses decimal octets; leading zeros can be interpreted as octal upstream. const ipv4Pattern = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/; if (ipv4Pattern.test(ip)) { return true; diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 369e59f2b..5f4eb8452 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -144,6 +144,10 @@ Copy the named exemplar instead of re-inventing: pills, `h-7 rounded-full px-2.5 text-xs` (IPHistory, DnsResolver). Never the default `spacing=0` connected form: its `border-l-0` / `first:border-l` seam only reads as one bar on a single line, and breaks the moment it wraps. +- **Shareable tool input** — a tool whose result is worth linking to reads its + query from `route.query.q` on mount and writes it back with `router.replace` + on every run (IpCalculator). Works on both `/tools/?q=` and + `/?tool=&q=`; `replace`, not `push`, so history doesn't grow per run. - **Fixed option sets** — a known, closed list of choices is a `Select`, not a toggle row, once it outgrows a comfortable single line (DnsResolver's record types, MtrTest's targets). diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index a51a62384..008d36fb0 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -277,7 +277,10 @@ watch( connectivityTests.push(entry); // Cards added after the bootstrap pass (import / hand-add) test // themselves right away instead of sitting at "Awaiting Test". - if (isStarted.value) checkConnectivityHandler(entry, () => { }, true); + if (isStarted.value) { + entry.status = t('connectivity.StatusTesting'); + checkConnectivityHandler(entry, () => { }, true); + } } }, { immediate: true, deep: true }, @@ -288,7 +291,7 @@ watch( const toneOf = (test) => { const okLabel = t('connectivity.StatusAvailable'); return ipFieldTone(test.status, { - waitLabels: t('connectivity.StatusWait'), + waitLabels: [t('connectivity.StatusWait'), t('connectivity.StatusTesting')], errorLabels: [t('connectivity.StatusUnavailable'), t('connectivity.StatusTimeout')], isSuccess: (s) => typeof s === 'string' && s.includes(okLabel), time: test.time, @@ -426,12 +429,16 @@ const checkAllConnectivity = (isAlertToShow, isRefresh, isManualRun) => { return new Promise((resolve) => { if (isRefresh) { connectivityTests.forEach((test) => { - test.status = t('connectivity.StatusWait'); test.statusCode = undefined; test.time = 0; }); trackEvent('Section', 'RefreshClick', 'Connectivity'); } + // Unjudged cards flip from "Awaiting Test" to "Testing…"; later + // multi-round ticks leave cards that already carry a verdict alone. + connectivityTests.forEach((test) => { + if (test.statusCode === undefined) test.status = t('connectivity.StatusTesting'); + }); // The pass judges only the targets it schedules here — cards added // mid-pass (self-tested by the watcher) belong to the next pass. diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index 07a5868a3..58c9aedb1 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -22,7 +22,7 @@
- @@ -33,8 +33,7 @@ #{{ index + 1 }}
- +

{{ leak.providerName }}

@@ -108,13 +107,15 @@ import FitText from '@/components/widgets/FitText.vue'; import InfoBanner from '@/components/widgets/InfoBanner.vue'; import { INLINE_TIERS } from '@/composables/use-fit-text.js'; import { - ipApi, surfshark, ipleak, fastly, runWithRetry, + ipApi, bashws, myipstack, fastly, browserleaks, surfshark, ipleak, + createDnsLeakRunner, } from '@/utils/dnsleaks'; -// One source of truth for which providers to run and in what order. Adding -// a new provider = create the file under utils/dnsleaks and append it here. -// (browserleaks stays available in utils/dnsleaks but is benched for now.) -const PROVIDERS = [ipApi, surfshark, ipleak, fastly]; +// All providers in priority order: the first SLOT_COUNT hold a card each, +// the rest are standbys. New provider = file under utils/dnsleaks + append. +const PROVIDERS = [ipApi, surfshark, myipstack, fastly, bashws, browserleaks, ipleak]; +const SLOT_COUNT = 4; +const ACTIVE = PROVIDERS.slice(0, SLOT_COUNT); const { t } = useI18n(); @@ -127,19 +128,20 @@ const userPreferences = computed(() => store.userPreferences); const isSimpleMode = computed(() => userPreferences.value.simpleMode); // Sticky settled flag for the section's banner slot. const hasEverSettled = ref(false); +let runId = 0; const { dotClass, textClass } = useStatusTone(); // Business status → 4 tone levels const toneOf = (leak) => ipFieldTone(leak.ip, { - waitLabels: t('dnsleaktest.StatusWait'), + waitLabels: [t('dnsleaktest.StatusWait'), t('dnsleaktest.StatusTesting')], errorLabels: t('dnsleaktest.StatusError'), }); // Status const isFieldPending = (value) => isFieldPendingShared(value, { - waitLabels: t('dnsleaktest.StatusWait'), + waitLabels: [t('dnsleaktest.StatusWait'), t('dnsleaktest.StatusTesting')], errorLabels: t('dnsleaktest.StatusError'), }); @@ -151,15 +153,19 @@ const createDefaultCard = () => ({ org: t('dnsleaktest.StatusWait'), }); -const leakTest = reactive(PROVIDERS.map((p) => ({ +// `slot` is the card's stable key; `id` / `providerName` follow the +// provider that actually answered. +const leakTest = reactive(ACTIVE.map((p) => ({ ...createDefaultCard(), + slot: p.id, id: p.id, providerName: p.name, }))); // Apply MaxMind lookup to a card that already has a resolved leak IP. -const applyMaxMindGeo = async (index, ip) => { +const applyMaxMindGeo = async (index, ip, currentRun) => { const geo = await lookupMaxmind(ip); + if (currentRun !== runId) return; if (geo) { leakTest[index].country_code = geo.country_code; leakTest[index].country = geo.country; @@ -178,48 +184,55 @@ const markLeakCardError = (index) => { leakTest[index].org = t('dnsleaktest.StatusError'); }; -// Run one provider against its card slot, with retry (3 attempts, fresh -// prefix per attempt — see `runWithRetry` in utils/dnsleaks). On full -// failure (all attempts threw) the card is flipped to the error state. -const runProvider = async (index, provider) => { +// Run one slot through its fallback chain (see utils/dnsleaks); the card +// flips to error only when the whole chain fails. +const runProvider = async (index, run, currentRun) => { + if (currentRun !== runId) return; try { - const { ip } = await runWithRetry(provider); + const { ip, provider } = await run(index); + if (currentRun !== runId) return; + leakTest[index].id = provider.id; + leakTest[index].providerName = provider.name; leakTest[index].ip = ip; - await applyMaxMindGeo(index, ip); + await applyMaxMindGeo(index, ip, currentRun); } catch (error) { + if (currentRun !== runId) return; console.error('Error fetching leak test data:', error); markLeakCardError(index); } }; -// Check all. Staggers startup by 300ms per provider to avoid a thundering- +// Check all. Staggers startup by 200ms per provider to avoid a thundering- // herd on first paint. const checkAllDNSLeakTest = async (isRefresh) => { + const currentRun = ++runId; + const run = createDnsLeakRunner(PROVIDERS, SLOT_COUNT); isStarted.value = true; - if (isRefresh) { - trackEvent('Section', 'RefreshClick', 'DNSLeakTest'); - leakTest.forEach((server) => { - server.ip = t('dnsleaktest.StatusWait'); - server.country = t('dnsleaktest.StatusWait'); - server.country_code = ''; - server.org = t('dnsleaktest.StatusWait'); - }); - } + if (isRefresh) trackEvent('Section', 'RefreshClick', 'DNSLeakTest'); + // Every run starts from the primary provider and "Testing…" — on boot + // this is a no-op for the slot, on refresh it drops the fallback result. + leakTest.forEach((server, index) => { + server.id = ACTIVE[index].id; + server.providerName = ACTIVE[index].name; + server.ip = t('dnsleaktest.StatusTesting'); + server.country = t('dnsleaktest.StatusTesting'); + server.country_code = ''; + server.org = t('dnsleaktest.StatusTesting'); + }); - const delayedRun = (provider, index, delay) => new Promise((resolve) => { + const delayedRun = (index, delay) => new Promise((resolve) => { setTimeout(() => { - runProvider(index, provider).then(resolve, resolve); + runProvider(index, run, currentRun).then(resolve, resolve); }, delay); }); - const promises = PROVIDERS.map((provider, index) => - delayedRun(provider, index, index * 200), - ); + const promises = ACTIVE.map((_, index) => delayedRun(index, index * 200)); const allSettledPromise = Promise.allSettled(promises); const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000)); return Promise.race([allSettledPromise, timeoutPromise]).then(() => { + if (currentRun !== runId) return; store.setLoadingStatus('DNSLeakTest', true); // Local sticky flag for the Enhanced DNS Leak Test banner hasEverSettled.value = true; diff --git a/frontend/components/SpeedTest.vue b/frontend/components/SpeedTest.vue index d17b413e7..382e947ee 100644 --- a/frontend/components/SpeedTest.vue +++ b/frontend/components/SpeedTest.vue @@ -88,8 +88,13 @@ - +
+

+ {{ progressLabel }} +

+ +
@@ -140,14 +145,14 @@

{{ t('speedtest.score') }} - {{ t('speedtest.videoStreaming') }} : + {{ t('speedtest.videoStreaming') }} : {{ t('speedtest.quality.' + - state.speedTest.streamingQuality) }} ; - {{ t('speedtest.gaming') }}: + state.speedTest.streamingQuality) }} ; + {{ t('speedtest.gaming') }}: {{ t('speedtest.quality.' + state.speedTest.gamingQuality) }} - ; - {{ t('speedtest.rtc') }}: + ; + {{ t('speedtest.rtc') }}: {{ t('speedtest.quality.' + state.speedTest.rtcQuality) }} @@ -172,7 +177,7 @@ diff --git a/frontend/components/advanced-tools/RuleTest.vue b/frontend/components/advanced-tools/RuleTest.vue index dd086a17c..7f0a425c8 100644 --- a/frontend/components/advanced-tools/RuleTest.vue +++ b/frontend/components/advanced-tools/RuleTest.vue @@ -71,7 +71,7 @@