diff --git a/api/AGENTS.md b/api/AGENTS.md index 056b4439..ae3d6287 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -21,6 +21,10 @@ proxies (`get-user-info` / `update-user-achievement`), platform (`configs` / `sentry-tunnel` / `share-report`). Each file's header comment states its route and purpose — read those for specifics. +`api/data/` holds contributor-editable static config consumed by handlers — +currently `dns-resolvers.js`, the country-annotated resolver list behind +`dns-resolver` (gated by `tests/dns-resolvers-data.test.js`). + ## Conventions - **Handler shape.** Single default export `async (req, res) => …`: read diff --git a/api/data/dns-resolvers.js b/api/data/dns-resolvers.js new file mode 100644 index 00000000..2ffab846 --- /dev/null +++ b/api/data/dns-resolvers.js @@ -0,0 +1,45 @@ +// api/data/dns-resolvers.js — the curated resolver list behind /api/dnsresolver. +// +// 👋 Contributors: this file is THE place to add a public DNS resolver. +// Add one object to DNS_RESOLVERS below; no other backend change is needed +// (the frontend groups results by `country` automatically). Entry shape: +// +// { +// id: 'google', // unique lowercase slug, [a-z0-9-] +// name: 'Google', // display name shown in the UI +// country: 'US', // ISO 3166-1 alpha-2, UPPERCASE +// // ('EU' allowed for EU-wide services) +// udp: '8.8.8.8', // optional — public UDP resolver IPv4 +// doh: 'https://dns.google/resolve?', // optional — DoH JSON endpoint prefix +// } +// +// Rules (enforced by tests/dns-resolvers-data.test.js — run `pnpm test`): +// - At least one of `udp` / `doh` per entry. +// - `doh` must be an https:// URL prefix ending in '?' or '&' — the handler +// appends `name=&type=` directly, so a prefix that already +// carries query params ends in '&' (see Cloudflare's `ct=…&`). The endpoint +// must speak the DNS-over-HTTPS JSON API (`Accept: application/dns-json`), +// not just RFC 8484 wire format. +// - `country` is where the operator is based (headquarters), not where the +// anycast nodes are. +// +// ⚠️ Keep this list curated, not exhaustive: EVERY resolver here adds one +// parallel upstream query per protocol to EVERY /api/dnsresolver request. +// Prefer well-known, stable, globally reachable services — and resolvers +// from countries not yet represented over a fifth US entry. +// +// Result ordering is stable: data-file order, and for providers with both +// protocols the udp lookup comes right before the doh one. + +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: 'adguard', name: 'AdGuard', country: 'CY', udp: '94.140.14.14', doh: 'https://dns.adguard.com/resolve?' }, + { 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: 'dns4eu', name: 'DNS4EU', country: 'EU', udp: '86.54.11.1' }, +]; diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 560f06b3..7b188e87 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -1,8 +1,11 @@ -// api/dnsresolver.js +// 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 { fetchUpstream } from '../common/fetch-with-timeout.js'; import logger from '../common/logger.js'; +import { DNS_RESOLVERS } from './data/dns-resolvers.js'; // Bound each upstream lookup so the slowest server doesn't pin the // overall response. 3s for UDP DNS (`Resolver` rejects on first @@ -11,28 +14,8 @@ import logger from '../common/logger.js'; const DNS_TIMEOUT_MS = 3000; const DOH_TIMEOUT_MS = 5000; -// Normal DNS server list -const dnsServers = { - 'Google': '8.8.8.8', - 'Cloudflare': '1.1.1.1', - 'OpenDNS': '208.67.222.222', - 'Quad9': '9.9.9.9', - 'ControlD': '76.76.2.0', - 'AdGuard': '94.140.14.14', - 'AliDNS': '223.5.5.5', - 'DNSPod': '119.29.29.29', - '114DNS': '114.114.114.114', - 'DNS4EU': '86.54.11.1', -}; - -// DNS-over-HTTPS server list -const dohServers = { - 'Google': 'https://dns.google/resolve?', - 'Cloudflare': 'https://cloudflare-dns.com/dns-query?ct=application/dns-json&', - 'AdGuard': 'https://dns.adguard.com/resolve?', - 'AliDNS': 'https://dns.alidns.com/resolve?', -}; - +// Resolve via classic UDP DNS. Returns the raw result value: an array of +// strings, a joined MX string, or 'N/A' on empty/failure. const resolveDns = async (hostname, type, name, server) => { const resolver = new Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 }); resolver.setServers([server]); @@ -74,19 +57,21 @@ const resolveDns = async (hostname, type, name, server) => { } if (addresses.length === 0 || addresses === '' || addresses === null) { - return { [name]: `N/A` }; + return 'N/A'; } - return { [name]: addresses }; + return addresses; } catch (error) { // Per-server timeouts are expected (some DNS hosts are unreachable // from a given network); demote to debug so they don't spam the // terminal during normal operation. logger.debug({ err: error, server: name }, 'DNS resolver: lookup failed, returning N/A'); - return { [name]: `N/A` }; + return 'N/A'; } }; +// Resolve via the DNS-over-HTTPS JSON API. Same return semantics as +// resolveDns. `url` is a prefix ending in '?' or '&' (see the data file). const resolveDoh = async (hostname, type, name, url) => { try { const response = await fetchUpstream(`${url}name=${hostname}&type=${type}`, { @@ -96,12 +81,12 @@ const resolveDoh = async (hostname, type, name, url) => { const data = await response.json(); const addresses = data.Answer ? data.Answer.map(answer => answer.data) : ['N/A']; if (addresses.length === 0 || addresses === '' || addresses === null) { - return { [name]: `N/A` }; + return 'N/A'; } - return { [name]: addresses }; + return addresses; } catch (error) { logger.debug({ err: error, server: name }, 'DoH resolver: lookup failed, returning N/A'); - return { [name]: `N/A` }; + return 'N/A'; } }; @@ -127,20 +112,36 @@ const dnsResolver = async (req, res) => { return res.status(400).send({ error: 'Invalid hostname' }); } - const dnsPromises = Object.entries(dnsServers).map(([name, ip]) => resolveDns(hostname, type, name, ip)); - const dohPromises = Object.entries(dohServers).map(([name, url]) => resolveDoh(hostname, type, name, url)); + // One lookup task per entry × protocol, in stable order: data-file order, + // udp before doh within a provider. Each task resolves to one row of the + // response; failures collapse to result 'N/A' inside the resolvers, so + // Promise.all never rejects here. + const lookups = DNS_RESOLVERS.flatMap((server) => { + const tasks = []; + if (server.udp) { + tasks.push(resolveDns(hostname, type, server.name, server.udp).then((result) => ({ + id: server.id, + provider: server.name, + country: server.country, + type: 'udp', + result, + }))); + } + if (server.doh) { + tasks.push(resolveDoh(hostname, type, server.name, server.doh).then((result) => ({ + id: server.id, + provider: server.name, + country: server.country, + type: 'doh', + result, + }))); + } + return tasks; + }); try { - // Execute all DNS and DoH queries in parallel - - const result_dns = await Promise.all(dnsPromises); - const result_doh = await Promise.all(dohPromises); - - res.json({ - hostname, - result_dns, - result_doh - }); + const results = await Promise.all(lookups); + res.json({ hostname, results }); } catch (error) { res.status(500).send({ error: error.message }); } diff --git a/frontend/components/advanced-tools/DnsResolver.vue b/frontend/components/advanced-tools/DnsResolver.vue index b45d01b3..cb6f32bc 100644 --- a/frontend/components/advanced-tools/DnsResolver.vue +++ b/frontend/components/advanced-tools/DnsResolver.vue @@ -37,45 +37,70 @@

{{ errorMsg }}

- - - -
- - - - - - - - - - - - - -
- {{ t('dnsresolver.Provider') }} - - {{ t('dnsresolver.Result') }} -
{{ result.provider }} - {{ result.address }} -
-
-
-
+ +
+ + + + {{ t('dnsresolver.AllRegions') }} + + + + {{ countryName(country) }} + + + + + +
+ + + + + + + + + + + + + +
+ {{ t('dnsresolver.Provider') }} + + {{ t('dnsresolver.Result') }} +
+ + + {{ result.provider }} + + + {{ result.address }} +
+
+
+
+
diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b668492b..c03384dd 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -968,7 +968,8 @@ "fetchError": "Unable to fetch resolution results", "Provider": "DNS Provider", "Result": "Resolution Result", - "Record": "Record" + "Record": "Record", + "AllRegions": "All Regions" }, "ruletest": { "Title": "Rule Test", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index a6d744ca..27425d9b 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -968,7 +968,8 @@ "fetchError": "Impossible de récupérer les résultats de résolution", "Provider": "Fournisseur DNS", "Result": "Résultat de la Résolution", - "Record": "Enregistrement" + "Record": "Enregistrement", + "AllRegions": "Toutes les régions" }, "ruletest": { "Title": "Test de règles", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 1cb6dfbd..3f2197c0 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -968,7 +968,8 @@ "fetchError": "Не удалось получить результаты разрешения", "Provider": "DNS-провайдер", "Result": "Результат разрешения", - "Record": "Запись" + "Record": "Запись", + "AllRegions": "Все регионы" }, "ruletest": { "Title": "Проверка правил", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 0878a8c8..d55e47e3 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -968,7 +968,8 @@ "fetchError": "无法获取解析结果", "Provider": "DNS 服务商", "Result": "解析结果", - "Record": "记录" + "Record": "记录", + "AllRegions": "全部地区" }, "ruletest": { "Title": "分流测试", diff --git a/tests/dns-resolvers-data.test.js b/tests/dns-resolvers-data.test.js new file mode 100644 index 00000000..57a1b19f --- /dev/null +++ b/tests/dns-resolvers-data.test.js @@ -0,0 +1,65 @@ +// Data-integrity gate for api/data/dns-resolvers.js — the file resolver- +// addition PRs touch. A new entry that breaks any rule here (bad country +// code, malformed doh prefix, duplicate id, …) fails `pnpm test` before it +// can break the live /api/dnsresolver endpoint. +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { DNS_RESOLVERS } from '../api/data/dns-resolvers.js'; +import { isValidIP, isIPv6 } from '../common/valid-ip.js'; + +// 'EU' is a CLDR-supported region code, so this also validates DNS4EU. +const regionNames = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'none' }); + +describe('DNS_RESOLVERS data integrity', () => { + it('is a non-empty array', () => { + assert.ok(Array.isArray(DNS_RESOLVERS)); + assert.ok(DNS_RESOLVERS.length > 0); + }); + + it('ids are unique lowercase slugs', () => { + const ids = DNS_RESOLVERS.map((r) => r.id); + assert.equal(new Set(ids).size, ids.length, 'duplicate id'); + for (const id of ids) { + assert.match(id, /^[a-z0-9-]+$/, `id "${id}" must be a lowercase slug`); + } + }); + + it('names are unique non-empty strings', () => { + const names = DNS_RESOLVERS.map((r) => r.name); + assert.equal(new Set(names).size, names.length, 'duplicate name'); + for (const name of names) { + assert.equal(typeof name, 'string'); + assert.ok(name.length > 0); + } + }); + + it('country is an uppercase two-letter code that Intl.DisplayNames resolves', () => { + for (const { id, country } of DNS_RESOLVERS) { + assert.match(country, /^[A-Z]{2}$/, `${id}: country "${country}" must be ISO alpha-2 uppercase`); + const resolved = regionNames.of(country); + assert.ok(resolved, `${id}: country "${country}" is not a known region code`); + } + }); + + it('every entry has at least one of udp / doh', () => { + for (const { id, udp, doh } of DNS_RESOLVERS) { + assert.ok(udp || doh, `${id}: needs a udp IP or a doh URL`); + } + }); + + it('udp, when present, is a valid IPv4 address', () => { + for (const { id, udp } of DNS_RESOLVERS) { + if (udp === undefined) continue; + assert.ok(isValidIP(udp) && !isIPv6(udp), `${id}: udp "${udp}" is not a valid IPv4`); + } + }); + + it("doh, when present, is https:// and ends with '?' or '&'", () => { + for (const { id, doh } of DNS_RESOLVERS) { + if (doh === undefined) continue; + assert.ok(doh.startsWith('https://'), `${id}: doh must be https://`); + assert.match(doh, /[?&]$/, `${id}: doh prefix must end with '?' or '&' — the handler appends name=&type=`); + } + }); +});