Feat(dnsresolver): country-annotated resolver list with region filter

Move the resolver list into api/data/dns-resolvers.js as structured
per-country data so adding a resolver becomes a one-object PR, gated
by a new data-integrity test. The API returns one flat annotated list
and the UI grows badge-style region filter chips (flags + localized
names via Intl.DisplayNames) above the result table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Fable 5 committed 2026-08-22 17:23:50 +08:00
1 parent d325144f83
commit a28aaeba0f
9 files changed
+245 -86

No files matched your search

+4
View File
@@ -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
+45
View File
@@ -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=<host>&type=<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' },
];
+42 -41
View File
@@ -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 });
}
@@ -37,45 +37,70 @@
<p v-if="errorMsg" class="text-sm text-destructive">{{ errorMsg }}</p>
</div>
<!-- Result table -->
<Card v-if="combinedResults && combinedResults.length">
<CardContent class="p-0">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b">
<th scope="col"
class="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
{{ t('dnsresolver.Provider') }}
</th>
<th scope="col"
class="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
{{ t('dnsresolver.Result') }}
</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="(result, index) in combinedResults" :key="index"
class="hover:bg-muted/50 transition-colors">
<td class="px-4 py-2.5 whitespace-nowrap font-medium">{{ result.provider }}</td>
<td class="px-4 py-2.5 font-mono wrap-break-word"
:class="result.address === 'N/A' ? 'text-muted-foreground/60' : ''">
{{ result.address }}
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
<!-- Results: region filter chips + provider table -->
<div v-if="combinedResults && combinedResults.length" class="space-y-3">
<!-- Country selector (first-appearance order from the response),
badge-shaped toggles: all regions + one per country. -->
<ToggleGroup :model-value="countryFilter" type="single" variant="outline" size="sm"
class="flex-wrap justify-start"
@update:model-value="(v) => v && (countryFilter = v)">
<ToggleGroupItem value="all" class="gap-1.5 cursor-pointer">
{{ t('dnsresolver.AllRegions') }}
</ToggleGroupItem>
<ToggleGroupItem v-for="country in resultCountries" :key="country" :value="country"
class="gap-1.5 cursor-pointer" :aria-label="countryName(country)">
<Icon :icon="'circle-flags:' + country.toLowerCase()" class="size-3.5 shrink-0" />
{{ countryName(country) }}
</ToggleGroupItem>
</ToggleGroup>
<Card>
<CardContent class="p-0">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b">
<th scope="col"
class="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
{{ t('dnsresolver.Provider') }}
</th>
<th scope="col"
class="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
{{ t('dnsresolver.Result') }}
</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="(result, index) in filteredResults" :key="result.country + '-' + index"
class="hover:bg-muted/50 transition-colors">
<td class="px-4 py-2.5 whitespace-nowrap font-medium">
<span class="flex items-center gap-2">
<Icon :icon="'circle-flags:' + result.country.toLowerCase()"
class="size-4 shrink-0" :title="countryName(result.country)" />
{{ result.provider }}
</span>
</td>
<td class="px-4 py-2.5 font-mono wrap-break-word"
:class="result.address === 'N/A' ? 'text-muted-foreground/60' : ''">
{{ result.address }}
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Icon } from '@iconify/vue';
import { trackEvent } from '@/utils/analytics';
import { isValidDomain } from '@/utils/valid-ip.js';
import getCountryName from '@/data/country-name.js';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -84,13 +109,14 @@ import { Spinner } from '@/components/ui/spinner';
import { Play } from '@lucide/vue';
import { Label } from '@/components/ui/label';
const { t } = useI18n();
const { t, locale } = useI18n();
const queryURL = ref('');
const queryType = ref('A');
const dnsCheckStatus = ref('idle');
const errorMsg = ref('');
const combinedResults = ref([]);
const countryFilter = ref('all');
const recordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT'];
@@ -133,14 +159,28 @@ const getDNSResults = async (hostname, type) => {
}
};
// Flatten the API's { id, provider, country, type, result } rows into
// display rows; the protocol keeps its label suffix on the provider name.
const processResults = (data) => {
const processEntries = (entries, kind) => entries.map(entry => {
const provider = Object.keys(entry)[0];
const address = Array.isArray(entry[provider]) ? entry[provider].join(', ') : entry[provider];
return { provider: `${provider} (${kind})`, address };
});
if (data.result_dns) combinedResults.value.push(...processEntries(data.result_dns, 'DNS'));
if (data.result_doh) combinedResults.value.push(...processEntries(data.result_doh, 'DoH 🔒'));
if (!Array.isArray(data.results)) return;
combinedResults.value = data.results.map(entry => ({
country: entry.country,
provider: `${entry.provider} (${entry.type === 'doh' ? 'DoH 🔒' : 'DNS'})`,
address: Array.isArray(entry.result) ? entry.result.join(', ') : entry.result,
}));
// A previously selected region may be absent from the new result set.
if (countryFilter.value !== 'all' && !resultCountries.value.includes(countryFilter.value)) {
countryFilter.value = 'all';
}
};
// Countries present in the results, first-appearance order.
const resultCountries = computed(() => [...new Set(combinedResults.value.map((r) => r.country))]);
const filteredResults = computed(() => (countryFilter.value === 'all'
? combinedResults.value
: combinedResults.value.filter((r) => r.country === countryFilter.value)));
// Localized country name via Intl.DisplayNames; fall back to the raw code.
const countryName = (code) => getCountryName(code, locale.value) || code;
</script>
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -968,7 +968,8 @@
"fetchError": "Не удалось получить результаты разрешения",
"Provider": "DNS-провайдер",
"Result": "Результат разрешения",
"Record": "Запись"
"Record": "Запись",
"AllRegions": "Все регионы"
},
"ruletest": {
"Title": "Проверка правил",
+2 -1
View File
@@ -968,7 +968,8 @@
"fetchError": "无法获取解析结果",
"Provider": "DNS 服务商",
"Result": "解析结果",
"Record": "记录"
"Record": "记录",
"AllRegions": "全部地区"
},
"ruletest": {
"Title": "分流测试",
+65
View File
@@ -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=<host>&type=<type>`);
}
});
});