mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
1 parent
d325144f83
commit
a28aaeba0f
9 files changed
+245
-86
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -968,7 +968,8 @@
|
||||
"fetchError": "Не удалось получить результаты разрешения",
|
||||
"Provider": "DNS-провайдер",
|
||||
"Result": "Результат разрешения",
|
||||
"Record": "Запись"
|
||||
"Record": "Запись",
|
||||
"AllRegions": "Все регионы"
|
||||
},
|
||||
"ruletest": {
|
||||
"Title": "Проверка правил",
|
||||
|
||||
@@ -968,7 +968,8 @@
|
||||
"fetchError": "无法获取解析结果",
|
||||
"Provider": "DNS 服务商",
|
||||
"Result": "解析结果",
|
||||
"Record": "记录"
|
||||
"Record": "记录",
|
||||
"AllRegions": "全部地区"
|
||||
},
|
||||
"ruletest": {
|
||||
"Title": "分流测试",
|
||||
|
||||
@@ -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>`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user