diff --git a/.gitignore b/.gitignore index 887bc6f90..0914cc8d3 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ scripts/* !scripts/fetch-favicons.js !scripts/i18n-status.js !scripts/i18n-scaffold.js +!scripts/check-dns-resolvers.js # Section banners — deploy-time data (ads and campaign promos stay out of git) frontend/data/banners/* diff --git a/api/AGENTS.md b/api/AGENTS.md index 04b5e5f56..b920d96b9 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -78,12 +78,19 @@ these checks: address it can't answer for — `isUsablePublicIP` in `common/valid-ip.js` is the single definition, shared with the front-end IP forms. - `requireValidDomain()` — `?domain=`, lowercases in place so the edge cache - sees one canonical key. + sees one canonical key. `isValidDomain` allows a leading underscore on any + label but the TLD, so RFC 8552 service names (`_dmarc.…`, `_domainkey.…`) + are reachable — that is what a DMARC or DKIM lookup needs. - `requireValidPrefix()` — `?prefix=` (CIDR); lets the frontend quantize to the BGP DFZ floor (/24 v4, /48 v6) for maximal CF edge-cache reuse. - `requireValidASN()` — `?asn=`, strips `AS`, rewrites to numeric (`cf-radar` predates it and still validates inline). - `requireValidProviderId()` — whitelists `?id=` against service-status slugs. +- `requireValidRecordType()` — whitelists `?type=` against `DNS_RECORD_TYPES` + in `common/dns-record-types.js` and uppercases it. That list is the single + source the picker in DnsResolver.vue and the `resolveDns` switch also read; + without the guard the DoH branch forwards any string verbatim to four + third-party endpoints. - `requireValidReportId()` — `/api/report/:id` route param (22-char base64url). New param shape → new guard in `common/guards.js`, attached in diff --git a/api/data/dns-resolvers.js b/api/data/dns-resolvers.js index 2ffab8468..78cd92e91 100644 --- a/api/data/dns-resolvers.js +++ b/api/data/dns-resolvers.js @@ -22,6 +22,11 @@ // not just RFC 8484 wire format. // - `country` is where the operator is based (headquarters), not where the // anycast nodes are. +// - VERIFY THE RESOLVER ACTUALLY ANSWERS before opening a PR — a documented +// IP is not enough. Query it from a machine outside the operator's country +// and confirm you get an answer with the `ra` flag set. Services get shut +// down and open resolvers get restricted to their own subscribers without +// the published docs ever being updated. // // ⚠️ Keep this list curated, not exhaustive: EVERY resolver here adds one // parallel upstream query per protocol to EVERY /api/dnsresolver request. @@ -38,8 +43,12 @@ export const DNS_RESOLVERS = [ { 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: 'yandex', name: 'Yandex.DNS', country: 'RU', udp: '77.88.8.8' }, { 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' }, ]; diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 7b188e87d..43c4359fe 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -6,6 +6,7 @@ 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'; +import { NAME_VALUED_TYPES } from '../common/dns-record-types.js'; // Bound each upstream lookup so the slowest server doesn't pin the // overall response. 3s for UDP DNS (`Resolver` rejects on first @@ -13,9 +14,68 @@ import { DNS_RESOLVERS } from './data/dns-resolvers.js'; // override. const DNS_TIMEOUT_MS = 3000; const DOH_TIMEOUT_MS = 5000; +const DNS_AVAILABILITY_ERRORS = new Set(['ETIMEOUT', 'ECONNREFUSED', 'EREFUSED']); + +const logDnsFailure = (error, server, provider) => { + // warn+ mirrors to telemetry and a DNS err.message carries the queried + // hostname, so the availability branch logs the code alone; the local-only + // debug branch keeps the full error. + if (DNS_AVAILABILITY_ERRORS.has(error?.code)) { + logger.warn({ server, provider, code: error?.code }, 'DNS resolver: availability lookup failed, returning N/A'); + return; + } + logger.debug({ err: error, server, provider, code: error?.code }, 'DNS resolver: lookup failed, returning N/A'); +}; + +// Node's resolveSoa strips the trailing root dot from both names; the DoH JSON +// path returns them in presentation form. Re-add them so the two rows a single +// provider contributes read identically — the MX branch below does the same. +export const formatSoaRecord = (record) => [ + `${record.nsname}.`, + `${record.hostmaster}.`, + record.serial, + record.refresh, + record.retry, + record.expire, + record.minttl, +].join(' '); + +// Node returns each CAA record as { critical, type: 'CAA', : value }, so +// the tag is whichever key is neither piece of metadata. Reading it that way +// renders a provider-specific tag as itself instead of dropping it. +const CAA_META_KEYS = new Set(['critical', 'type']); + +export const formatCaaRecords = (records) => records.flatMap((record) => { + const tagged = Object.entries(record).find(([key]) => !CAA_META_KEYS.has(key)); + if (!tagged) return []; + const [tag, value] = tagged; + return `${record.critical ?? 0} ${tag} ${JSON.stringify(value)}`; +}).join(', '); + +// Both transports run name-valued answers through this: Node's resolver +// returns `dns.google`, a DoH endpoint returns `dns.google.`, and that lone +// dot would read as two providers disagreeing. +export const withRootDot = (name) => (name.endsWith('.') ? name : `${name}.`); + +// DNS numeric type for SOA, used to pick the zone's SOA out of a DoH authority +// section (see dohRecords). +const SOA_RECORD_TYPE = 6; + +// The records a DoH envelope actually answers with. A SOA query for a name +// below the zone apex carries the zone's own SOA in the authority section +// instead, so fall back to it — otherwise any hostname that isn't itself a zone +// reports N/A on every DoH row. SOA answers are filtered by type because a +// CNAME name puts the chain in Answer with the SOA in Authority, and the CNAME +// target must not render as the SOA result. +export const dohRecords = (data, type) => { + if (type !== 'SOA') return data.Answer ?? []; + const answers = (data.Answer ?? []).filter((record) => record.type === SOA_RECORD_TYPE); + if (answers.length) return answers; + return (data.Authority ?? []).filter((record) => record.type === SOA_RECORD_TYPE); +}; // Resolve via classic UDP DNS. Returns the raw result value: an array of -// strings, a joined MX string, or 'N/A' on empty/failure. +// strings, a formatted record 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]); @@ -25,6 +85,8 @@ const resolveDns = async (hostname, type, name, server) => { 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; @@ -52,20 +114,25 @@ const resolveDns = async (hostname, type, name, server) => { addresses = addresses.map(item => `${item.priority} ${item.exchange}.`) .join(', '); break; + case 'SOA': + addresses = formatSoaRecord(await resolveSoaAsync(hostname)); + break; + case 'CAA': + addresses = formatCaaRecords(await resolveCaaAsync(hostname)); + break; default: throw new Error('Unsupported type'); } + if (NAME_VALUED_TYPES.has(type)) addresses = addresses.map(withRootDot); + if (addresses.length === 0 || addresses === '' || addresses === null) { return 'N/A'; } 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'); + logDnsFailure(error, server, name); return 'N/A'; } }; @@ -78,14 +145,16 @@ const resolveDoh = async (hostname, type, name, url) => { timeoutMs: DOH_TIMEOUT_MS, headers: { 'Accept': 'application/dns-json' } }); - const data = await response.json(); - const addresses = data.Answer ? data.Answer.map(answer => answer.data) : ['N/A']; - if (addresses.length === 0 || addresses === '' || addresses === null) { + if (!response.ok) { + logger.warn({ server: name, code: response.status }, 'DoH resolver: upstream returned a non-2xx response'); return 'N/A'; } - return addresses; + const records = dohRecords(await response.json(), type); + if (records.length === 0) return 'N/A'; + const addresses = records.map((record) => record.data); + return NAME_VALUED_TYPES.has(type) ? addresses.map(withRootDot) : addresses; } catch (error) { - logger.debug({ err: error, server: name }, 'DoH resolver: lookup failed, returning N/A'); + logger.warn({ err: error, server: name, code: error?.code }, 'DoH resolver: lookup failed, returning N/A'); return 'N/A'; } }; @@ -98,20 +167,9 @@ const dnsResolver = async (req, res) => { return res.status(405).json({ message: 'Method Not Allowed' }); } + // Hostname presence, shape and lowercasing are guaranteed by requireValidDomain. const { hostname, type } = req.query; - if (typeof hostname !== 'string') { - return res.status(400).send({ error: 'Hostname parameter must be a string' }); - } - - if (!hostname) { - return res.status(400).send({ error: 'Missing hostname parameter' }); - } - - if (!hostname.includes('.')) { - return res.status(400).send({ error: 'Invalid hostname' }); - } - // 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 @@ -143,8 +201,10 @@ const dnsResolver = async (req, res) => { const results = await Promise.all(lookups); res.json({ hostname, results }); } catch (error) { + logger.error({ err: error }, 'DNS resolver handler failed'); res.status(500).send({ error: error.message }); } }; +export { resolveDns, resolveDoh }; export default dnsResolver; diff --git a/backend-server.js b/backend-server.js index dba20bc01..af67af8db 100644 --- a/backend-server.js +++ b/backend-server.js @@ -7,7 +7,8 @@ import { slowDown } from 'express-slow-down' import rateLimit from 'express-rate-limit'; import pinoHttp from 'pino-http'; import logger from './common/logger.js'; -import { requireReferer, requirePublicIP, requireValidPrefix, requireValidASN, requireValidDomain, requireValidProviderId, requireValidReportId } from './common/guards.js'; +import { requireReferer, requirePublicIP, requireValidPrefix, requireValidASN, requireValidDomain, requireValidProviderId, + requireValidRecordType, requireValidReportId } from './common/guards.js'; import { withTimeZone } from './common/ip-timezone.js'; // Backend APIs @@ -275,7 +276,7 @@ app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker); app.get('/api/map', cacheable(ONE_YEAR_CACHE), mapHandler); // Non-cacheable routes — auth-context, debug tools, or per-request lookups. app.get('/api/ipchecking', requirePublicIP(), withTimeZone(), ipCheckingHandler); -app.get('/api/dnsresolver', dnsResolver); +app.get('/api/dnsresolver', requireValidDomain('hostname'), requireValidRecordType(), dnsResolver); app.get('/api/dnsleaktest/session/:token', dnsLeakGetResult); app.get('/api/invisibility', invisibilitytestHandler); app.get('/api/getuserinfo', getUserinfo); diff --git a/common/dns-record-types.js b/common/dns-record-types.js new file mode 100644 index 000000000..36cfb18b8 --- /dev/null +++ b/common/dns-record-types.js @@ -0,0 +1,13 @@ +// The DNS record types /api/dnsresolver answers for — one list behind three +// consumers: the record-type - - + + + + + + + + + +

{{ errorMsg }}

- - + - + {{ t('dnsresolver.AllRegions') }} + :class="tagClass" :aria-label="countryName(country)"> - {{ countryName(country) }} + {{ countryName(country) }} @@ -100,11 +111,14 @@ import { useI18n } from 'vue-i18n'; import { Icon } from '@iconify/vue'; import { trackEvent } from '@/utils/analytics'; import { isValidDomain } from '@/utils/valid-ip.js'; +import { DNS_RECORD_TYPES } from '@/utils/dns-record-types.js'; import getCountryName from '@/data/country-name.js'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; import { Card, CardContent } from '@/components/ui/card'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { Spinner } from '@/components/ui/spinner'; import { Play } from '@lucide/vue'; import { Label } from '@/components/ui/label'; @@ -118,9 +132,13 @@ const errorMsg = ref(''); const combinedResults = ref([]); const countryFilter = ref('all'); -const recordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT']; +const recordTypes = DNS_RECORD_TYPES; + +// Region filter pills, matching IPHistory's tag row. +const tagClass = 'group h-7 rounded-full px-2.5 text-xs cursor-pointer'; const validateInput = (input) => { + input = input.trim(); if (!input.match(/^https?:\/\//)) input = 'http://' + input; try { const url = new URL(input); diff --git a/frontend/components/ui/button-group/ButtonGroup.vue b/frontend/components/ui/button-group/ButtonGroup.vue new file mode 100644 index 000000000..21b93d35a --- /dev/null +++ b/frontend/components/ui/button-group/ButtonGroup.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/components/ui/button-group/ButtonGroupSeparator.vue b/frontend/components/ui/button-group/ButtonGroupSeparator.vue new file mode 100644 index 000000000..9da1bdc58 --- /dev/null +++ b/frontend/components/ui/button-group/ButtonGroupSeparator.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/components/ui/button-group/ButtonGroupText.vue b/frontend/components/ui/button-group/ButtonGroupText.vue new file mode 100644 index 000000000..25fb7ad15 --- /dev/null +++ b/frontend/components/ui/button-group/ButtonGroupText.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/components/ui/button-group/index.js b/frontend/components/ui/button-group/index.js new file mode 100644 index 000000000..d9676c35b --- /dev/null +++ b/frontend/components/ui/button-group/index.js @@ -0,0 +1,22 @@ +import { cva } from "class-variance-authority"; + +export { default as ButtonGroup } from "./ButtonGroup.vue"; +export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue"; +export { default as ButtonGroupText } from "./ButtonGroupText.vue"; + +export const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + }, +); diff --git a/frontend/components/ui/separator/Separator.vue b/frontend/components/ui/separator/Separator.vue index b223481ea..e97cc9c60 100644 --- a/frontend/components/ui/separator/Separator.vue +++ b/frontend/components/ui/separator/Separator.vue @@ -1,19 +1,28 @@ diff --git a/frontend/components/ui/separator/index.js b/frontend/components/ui/separator/index.js index 3cb07b017..aae7f1a62 100644 --- a/frontend/components/ui/separator/index.js +++ b/frontend/components/ui/separator/index.js @@ -1 +1 @@ -export { default as Separator } from './Separator.vue'; +export { default as Separator } from "./Separator.vue"; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 0c4b71e32..d08273a6c 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -961,7 +961,7 @@ }, "dnsresolver": { "Title": "DNS Resolution", - "Note": "In some regions, due to political or commercial reasons, some operators may contaminate certain domain names, resulting in incorrect results when accessed directly. Using DNS resolution checks will help you inspect the resolution results of domain names from well-known DNS providers around the world. Among the built-in DNS tests, some providers are from China, whose DNS resolution results may be contaminated. Please be discerning.", + "Note": "Resolve one domain against well-known public DNS providers around the world at the same time — over both classic DNS and DNS-over-HTTPS — and compare what each one answers. When the answers differ between providers or regions, it usually means a CDN is steering traffic to a nearby node, a cache somewhere is stale, or the record is being filtered or redirected along the way. Pick a record type, enter a domain or URL, and run the query: each row shows which country the resolver sits in, and the table can be filtered by region.", "Note2": "Please enter a URL or domain name to start resolution:", "Placeholder": "URL or Domain Name", "invalidURL": "Invalid URL or Domain Name", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 08208b2af..9ae4ced8f 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -961,7 +961,7 @@ }, "dnsresolver": { "Title": "Résolution DNS", - "Note": "Dans certaines régions, pour des raisons politiques ou commerciales, certains opérateurs peuvent contaminer certains noms de domaine, entraînant des résultats incorrects lorsqu'ils sont accédés directement. L'utilisation de vérifications de résolution DNS vous aidera à inspecter les résultats de résolution de noms de domaine auprès de fournisseurs DNS renommés dans le monde entier. Parmi les tests DNS intégrés, certains fournisseurs sont originaires de Chine, dont les résultats de résolution DNS peuvent être contaminés. Veuillez faire preuve de discernement.", + "Note": "Résolvez un même domaine auprès de fournisseurs DNS publics reconnus dans le monde entier, simultanément et aussi bien en DNS classique qu'en DNS-over-HTTPS, puis comparez ce que chacun renvoie. Lorsque les réponses diffèrent d'un fournisseur ou d'une région à l'autre, c'est généralement le signe qu'un CDN oriente le trafic vers un nœud proche, qu'un cache n'est plus à jour, ou que l'enregistrement est filtré ou redirigé quelque part sur le chemin. Choisissez un type d'enregistrement, saisissez un domaine ou une URL, puis lancez la requête : chaque ligne indique le pays du résolveur, et le tableau peut être filtré par région.", "Note2": "Veuillez entrer une URL ou un nom de domaine pour commencer la résolution :", "Placeholder": "URL ou Nom de Domaine", "invalidURL": "URL ou Nom de Domaine invalide", diff --git a/frontend/locales/privacy/pt-BR.json b/frontend/locales/privacy/pt-BR.json new file mode 100644 index 000000000..62ff9b08c --- /dev/null +++ b/frontend/locales/privacy/pt-BR.json @@ -0,0 +1,111 @@ +{ + "privacy": { + "Title": "Política de Privacidade", + "UpdatedLabel": "Última atualização", + "Intro": "O IPCheck.ing é um conjunto de ferramentas de IP gratuito e de código aberto. Nós o criamos para ajudar você a entender o que sua rede e seu navegador revelam a seu respeito, por isso respeitar sua privacidade é importante para nós. Esta página explica o que acontece com seus dados quando você usa o site.", + "sections": { + "tools": { + "title": "Como as ferramentas funcionam", + "paragraphs": [ + "A maior parte do que as ferramentas mostram — detalhes do seu IP, impressão digital do navegador, DNS, conectividade e resultados do teste de velocidade — é calculada no seu navegador ou consultada em tempo real e exibida somente para você. Não mantemos esses resultados em nossos servidores para que outras pessoas os acessem, a menos que você crie explicitamente um link de relatório compartilhável.", + "Para consultar a geolocalização de um IP, o endereço IP pesquisado é enviado a fornecedores terceirizados (como ipinfo.io, ip-api.com e similares). Essas consultas são regidas pela política de privacidade de cada fornecedor." + ] + }, + "pulse": { + "title": "Earth Online", + "paragraphs": [ + "Quando você visita o site, contabilizamos uma visita anônima junto com seu país ou região, obtidos a partir do seu endereço IP em nossa rede de borda. O IP em si é usado apenas de forma temporária; o endereço completo nunca é armazenado.", + "Se você optar por compartilhar um status predefinido, registramos o identificador do status, seu país ou região e o prefixo mascarado do IP, e os dados são excluídos automaticamente em poucos dias. Os status vêm de uma lista fixa — não há entrada de texto livre." + ] + }, + "sharedReports": { + "title": "Relatórios diagnósticos compartilhados", + "paragraphs": [ + "Você pode reunir os resultados dos seus testes em um relatório diagnóstico e compartilhá-lo. Criar um link de compartilhamento é sempre uma ação explícita — nada é enviado automaticamente. Você escolhe quais seções de teste incluir e pode mascarar os dígitos finais dos endereços IP antes que o link seja criado.", + "Quando você cria um link, os resultados selecionados são armazenados em nossa infraestrutura de servidores (Cloudflare Workers KV) para que as pessoas com quem você compartilhou o link possam abrir o relatório. O relatório é excluído automaticamente após o período de retenção escolhido (1, 3 ou 7 dias). Os links usam identificadores aleatórios impossíveis de adivinhar, não mantemos uma lista dos relatórios existentes e qualquer pessoa que tenha o link pode visualizar o relatório até que ele expire.", + "Copiar um relatório para um assistente de IA ou baixá-lo como JSON acontece inteiramente no seu navegador e não armazena nada em nossos servidores." + ] + }, + "personaCheck": { + "title": "Verificação aprofundada de persona", + "paragraphs": [ + "A Verificação aprofundada de persona envia seus dados ao nosso serviço de pontuação, que os avalia em relação ao país escolhido e devolve o relatório com a nota. Nem o que você envia nem o relatório que recebe são armazenados — a menos que você reúna explicitamente o resultado em um link de relatório compartilhável; mesmo nesse caso, apenas a nota e o veredito de cada verificação são incluídos, nunca os valores em que se baseiam.", + "O que é enviado em uma execução:" + ], + "bullets": [ + "Suas configurações de fuso horário e idioma, além da forma como seu navegador formata uma data e um número de exemplo fixos.", + "Os sistemas de escrita abrangidos pelas fontes instaladas e pelas vozes de síntese de fala, além do layout do teclado — as mesmas informações que o relatório mostra. A leitura desses dados torna seu navegador um pouco mais identificável de modo geral, algo inerente à medição.", + "Os resultados que já estão na página, provenientes da consulta de IP e dos testes de WebRTC e DNS.", + "Somente se você os fornecer: sua localização, arredondada para aproximadamente um quilômetro antes de sair do dispositivo e usada apenas para determinar um país; e os primeiros 6 a 8 dígitos de um cartão de pagamento. Esses dígitos identificam o banco emissor, nunca sua conta, e não permitem realizar pagamentos — eles são verificados em um banco de dados terceirizado de emissores de cartões." + ] + }, + "docsAssistant": { + "title": "Assistente de documentação", + "paragraphs": [ + "O assistente de documentação responde a perguntas com base em nosso site de documentação. Ele só é iniciado quando você o utiliza: o assistente é carregado sob demanda, e a pergunta digitada é enviada ao GitBook, que hospeda nossa documentação e fornece o assistente, para gerar uma resposta.", + "O assistente também pode ler os resultados de teste exibidos no momento em sua página — seus endereços IP com a localização e a rede, além dos resultados de todos os testes executados. Isso nunca acontece sem seu conhecimento: o assistente precisa pedir, e você autoriza por meio do botão de confirmação exibido no chat. Mesmo que você recuse, ele ainda poderá responder com base na documentação.", + "A conversa ocorre entre seu navegador e o GitBook; ela não passa por nossos servidores, e nós não a armazenamos. Os próprios termos de privacidade do GitBook se aplicam às mensagens que você envia." + ] + }, + "analytics": { + "title": "O que coletamos por meio da análise", + "paragraphs": [ + "Usamos o Google Analytics para entender como o site é utilizado. Por meio dele, são coletados os seguintes dados:" + ], + "bullets": [ + "Uma localização aproximada obtida a partir do seu endereço IP (país / região / cidade). O Google não compartilha seu endereço IP completo conosco.", + "O idioma do seu navegador (por exemplo, en ou zh).", + "Dados de análise padrão: páginas visitadas, tipo de dispositivo e navegador, site de referência e um identificador de visitante gerado aleatoriamente e armazenado em um cookie." + ] + }, + "account": { + "title": "O que coletamos quando você entra na conta", + "paragraphs": [ + "Entrar na conta é opcional — você pode usar a maioria das ferramentas do site sem uma conta. O login não é uma forma de cobrarmos de você, agora nem no futuro; ele é necessário para impedir o uso abusivo do serviço. Usamos o Google Firebase Authentication para gerenciar seu login e sincronizar suas conquistas. Quando você está conectado, são coletados os seguintes dados:" + ], + "bullets": [ + "Seu endereço de e-mail usado no login, utilizado para identificar sua conta e fornecer os recursos que exigem autenticação.", + "A quantidade de vezes que você utiliza os recursos avançados, usada para detectar uso indevido mal-intencionado.", + "Ao final de cada período de contabilização, os registros de análise de abuso de cada usuário conectado, se houver, são excluídos automaticamente; apenas os totais de uso são mantidos." + ] + }, + "telemetry": { + "title": "Telemetria de erros e desempenho", + "paragraphs": [ + "Esta implantação usa o Sentry, um serviço de monitoramento de erros, para que possamos saber quando o site apresenta falhas e corrigi-las. A telemetria é enviada ao Sentry por meio de um retransmissor hospedado em nosso próprio servidor e não é compartilhada com mais ninguém. Ela inclui:" + ], + "bullets": [ + "Relatórios técnicos de erros: o que deu errado no código, em qual página e o tipo de navegador e sistema operacional que você utiliza.", + "Métricas de desempenho, como a velocidade de carregamento das páginas.", + "Quando ocorre um erro, uma reprodução dos momentos que o antecederam, para que possamos reproduzir o problema.", + "Seu endereço IP, anexado aos relatórios de erro quando eles passam pelo nosso retransmissor. Problemas relacionados à rede muitas vezes não podem ser diagnosticados sem ele, e isso nos permite estimar quantos visitantes são afetados por um erro. O endereço nunca é compartilhado e é excluído junto com o restante da telemetria.", + "Todos os dados de telemetria são excluídos automaticamente após um período de retenção de 30 dias." + ] + }, + "why": { + "title": "Por que coletamos esses dados", + "analytics": "Usamos os dados de análise para entender como o site é utilizado e melhorar a experiência do usuário. Esses dados não são usados para identificar você pessoalmente, e nunca os vendemos.", + "account": "Os dados da sua conta (e-mail e quantidade de usos dos recursos avançados) são usados apenas para identificar sua conta e fornecer os recursos que exigem login. Não os usamos para publicidade e nunca os vendemos.", + "telemetry": "A telemetria de erros e desempenho é usada exclusivamente para localizar e corrigir falhas. Ela nunca é usada para publicidade nem para rastrear você em outros sites, e é excluída automaticamente após um curto período de retenção." + }, + "cookies": { + "title": "Cookies e armazenamento local", + "analytics": "O Google Analytics armazena um cookie para reconhecer visitantes que retornam.", + "local": "Suas configurações (tema, idioma e preferências de ferramentas) são mantidas no armazenamento local do seu navegador e nunca saem do seu dispositivo." + }, + "eu": { + "title": "Se você estiver na UE/EEE ou no Reino Unido", + "paragraphs": [ + "No momento, não exibimos um banner de consentimento de cookies. Se você preferir não ser incluído na análise, poderá desativá-la a qualquer momento usando os métodos abaixo — bloquear o cookie de análise não afeta nenhuma ferramenta do site.", + "Para desativar a coleta: use um bloqueador de conteúdo (como o uBlock Origin) ou a extensão oficial do Google para desativar o Google Analytics, bloqueie cookies de terceiros ou limpe os cookies deste site. Cópias auto-hospedadas do IPCheck.ing só executam a análise se o operador a configurar." + ] + }, + "retention": { + "title": "Retenção de dados", + "paragraphs": [ + "Os dados de análise são mantidos de acordo com as configurações de retenção do Google Analytics (por até 14 meses) e depois são excluídos automaticamente. Os dados da conta são mantidos enquanto sua conta existir." + ] + } + } + } +} diff --git a/frontend/locales/pt-BR.json b/frontend/locales/pt-BR.json index bf347f931..f7693d94f 100644 --- a/frontend/locales/pt-BR.json +++ b/frontend/locales/pt-BR.json @@ -961,7 +961,7 @@ }, "dnsresolver": { "Title": "Resolução DNS", - "Note": "Em algumas regiões, por motivos políticos ou comerciais, algumas operadoras podem contaminar determinados nomes de domínio, resultando em respostas incorretas quando acessados diretamente. Usar verificações de resolução DNS ajuda a inspecionar os resultados de nomes de domínio em provedores DNS conhecidos no mundo todo. Entre os testes DNS integrados, alguns provedores são da China, cujos resultados de resolução DNS podem estar contaminados. Analise com critério.", + "Note": "Resolva um mesmo domínio em provedores de DNS públicos conhecidos no mundo todo ao mesmo tempo — por DNS clássico e por DNS-over-HTTPS — e compare o que cada um responde. Quando as respostas variam entre provedores ou regiões, normalmente significa que um CDN está direcionando o tráfego para um nó próximo, que algum cache está desatualizado, ou que o registro está sendo filtrado ou redirecionado em algum ponto do caminho. Escolha um tipo de registro, informe um domínio ou URL e execute a consulta: cada linha indica o país do resolvedor, e a tabela pode ser filtrada por região.", "Note2": "Digite uma URL ou nome de domínio para iniciar a resolução:", "Placeholder": "URL ou nome de domínio", "invalidURL": "URL ou nome de domínio inválido", diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json index 08d4d4922..4b1879e3a 100644 --- a/frontend/locales/ru.json +++ b/frontend/locales/ru.json @@ -961,7 +961,7 @@ }, "dnsresolver": { "Title": "Разрешение DNS", - "Note": "В некоторых регионах операторы по политическим или коммерческим причинам могут подменять результаты для определённых доменов. Проверка разрешения DNS показывает ответы известных DNS-провайдеров со всего мира. Среди встроенных тестов DNS есть китайские провайдеры, чьи результаты разрешения DNS могут быть подменены. Учитывайте это при оценке.", + "Note": "Разрешите одно и то же доменное имя одновременно на известных публичных DNS-серверах по всему миру — как по обычному DNS, так и по DNS-over-HTTPS — и сравните ответы. Расхождения между провайдерами или регионами обычно означают, что CDN направляет трафик к ближайшему узлу, что где-то устарел кеш или что запись по пути фильтруется либо подменяется. Выберите тип записи, введите домен или URL и запустите проверку: в каждой строке указана страна резолвера, а таблицу можно отфильтровать по региону.", "Note2": "Введите URL или доменное имя, чтобы начать разрешение:", "Placeholder": "URL или доменное имя", "invalidURL": "Недействительный URL или доменное имя", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 47e9aea8c..acc7a9573 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -961,7 +961,7 @@ }, "dnsresolver": { "Title": "DNS 解析", - "Note": "在一些地区,某些运营商可能会因为政治原因或者商业原因,对一些域名进行污染,以至于直接进行访问的时候,无法返回正确的结果。使用 DNS 解析检查,将帮助你从全球各个知名的 DNS 厂商里检查域名的解析结果。在检测内置的 DNS 中,有一部分服务商来自中国,其 DNS 解析的结果可能会受到污染。请注意鉴别。", + "Note": "同时向全球知名的公共 DNS 服务商解析同一个域名,覆盖传统 DNS 与 DNS over HTTPS 两种方式,并对比各家返回的结果。不同服务商或不同地区的结果出现差异,通常意味着 CDN 在做就近调度、某处的缓存尚未更新,或者这条记录在链路上被过滤或重定向了。使用时先选择记录类型,再填入域名或网址并运行:每一行都会标注该解析服务器所在的国家,表格也可以按地区筛选。", "Note2": "请输入 URL 或域名,开始进行解析:", "Placeholder": "URL 或域名", "invalidURL": "无效的 URL 或域名", diff --git a/frontend/utils/dns-record-types.js b/frontend/utils/dns-record-types.js new file mode 100644 index 000000000..c3e7ececf --- /dev/null +++ b/frontend/utils/dns-record-types.js @@ -0,0 +1,6 @@ +// Thin re-export — implementation lives in common/dns-record-types.js so the +// record-type picker and the backend guard read the same list (same pattern as +// valid-ip.js and bgp-prefix.js). +// +// import { DNS_RECORD_TYPES } from '@/utils/dns-record-types.js'; +export { DNS_RECORD_TYPES } from '../../common/dns-record-types.js'; diff --git a/package.json b/package.json index 64a1a57bf..17f805b4f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "start-frontend": "node frontend-server.js", "start": "concurrently \"node frontend-server.js\" \"node --import ./sentry-instrument.js backend-server.js\"", "purge-index": "node scripts/purge-index-cache.js", + "dns-check": "node scripts/check-dns-resolvers.js", "fetch-favicons": "node scripts/fetch-favicons.js", "i18n-status": "node scripts/i18n-status.js", "i18n-new": "node scripts/i18n-scaffold.js new", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7332407a..d14449e0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3978,7 +3978,7 @@ snapshots: '@vue/shared': 3.5.41 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.25 + postcss: 8.5.26 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.39': diff --git a/scripts/check-dns-resolvers.js b/scripts/check-dns-resolvers.js new file mode 100644 index 000000000..cd8ebc1e1 --- /dev/null +++ b/scripts/check-dns-resolvers.js @@ -0,0 +1,263 @@ +// Check the curated UDP DNS resolvers for recursive availability. + +import { execFile } from 'node:child_process'; +import process from 'node:process'; +import { promisify } from 'node:util'; + +import { DNS_RESOLVERS } from '../api/data/dns-resolvers.js'; + +const execFileAsync = promisify(execFile); + +export const DEFAULT_QUERY_NAME = 'example.com'; +export const DEFAULT_NXDOMAIN_NAME = 'resolver-health-check.invalid'; +export const DEFAULT_ATTEMPTS = 2; +export const DEFAULT_TIMEOUT_MS = 8000; +export const DEFAULT_RETRY_DELAY_MS = 250; + +const DIG_ARGUMENTS = ['+noall', '+comments', '+answer']; + +const sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)); + +export const ensureDigAvailable = async (exec = execFileAsync) => { + try { + await exec('dig', ['-v'], { timeout: 5000, maxBuffer: 64 * 1024 }); + } catch (error) { + if (error?.code === 'ENOENT') { + throw new Error('The `dig` executable is required for DNS resolver checks; install dnsutils (Debian/Ubuntu) or bind (macOS).'); + } + throw new Error(`Unable to run 'dig': ${error instanceof Error ? error.message : String(error)}`); + } +}; + +const parseAttempts = (value) => { + const attempts = Number.parseInt(value, 10); + if (!Number.isInteger(attempts) || attempts < 1) { + throw new Error(`--attempts must be a positive integer (received: ${value})`); + } + return attempts; +}; + +const parseCliArgs = (argv) => { + const options = { json: false, attempts: DEFAULT_ATTEMPTS }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--json') { + options.json = true; + continue; + } + if (argument === '--attempts') { + index += 1; + if (index >= argv.length) throw new Error('--attempts needs a value'); + options.attempts = parseAttempts(argv[index]); + continue; + } + if (argument.startsWith('--attempts=')) { + options.attempts = parseAttempts(argument.slice('--attempts='.length)); + continue; + } + throw new Error(`Unknown option: ${argument}`); + } + + return options; +}; + +const normalizeResponse = (response = {}) => ({ + code: response.code ?? 0, + stdout: typeof response.stdout === 'string' ? response.stdout : '', + stderr: typeof response.stderr === 'string' ? response.stderr : '', +}); + +const responseFailure = (response) => { + if (response.stderr.trim()) { + const lastLine = response.stderr.trim().split('\n').at(-1)?.trim(); + if (lastLine) return lastLine; + } + return `dig exited with code ${String(response.code)}`; +}; + +/** + * Parse the stable status, recursion, and answer-count fields from dig output. + * Keeping this separate makes the network runner replaceable in unit tests. + */ +export const parseDigResponse = (output, { expectedStatus, requireAnswer }) => { + const text = typeof output === 'string' ? output : ''; + const status = text.match(/\bstatus:\s*([A-Z]+)\b/i)?.[1]?.toUpperCase(); + const flagsText = text.match(/\bflags:\s*([^;]*);/i)?.[1] ?? ''; + const flags = flagsText.trim().toLowerCase().split(/\s+/).filter(Boolean); + const answerCountMatch = text.match(/\bANSWER:\s*(\d+)/i); + const answerCount = answerCountMatch ? Number.parseInt(answerCountMatch[1], 10) : undefined; + const expected = expectedStatus.toUpperCase(); + + if (!status) return { ok: false, reason: 'no DNS status in dig output' }; + if (status !== expected) return { ok: false, reason: `expected ${expected}, got ${status}`, status, flags, answerCount }; + if (!flags.includes('ra')) return { ok: false, reason: 'response is missing the ra flag', status, flags, answerCount }; + if (answerCount === undefined) return { ok: false, reason: 'no DNS answer count in dig output', status, flags }; + if (requireAnswer && answerCount < 1) return { ok: false, reason: 'NOERROR response has no answers', status, flags, answerCount }; + if (!requireAnswer && answerCount !== 0) return { ok: false, reason: `NXDOMAIN response has ${answerCount} answers`, status, flags, answerCount }; + + return { ok: true, status, flags, answerCount }; +}; + +/** Run one UDP dig query without invoking a shell. */ +export const runDig = async (resolverIp, name, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) => { + const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000)); + const args = [`+time=${timeoutSeconds}`, '+tries=1', ...DIG_ARGUMENTS, `@${resolverIp}`, name, 'A']; + + try { + const result = await execFileAsync('dig', args, { timeout: timeoutMs, maxBuffer: 64 * 1024 }); + return normalizeResponse({ ...result, code: 0 }); + } catch (error) { + return normalizeResponse({ + code: error.code ?? 1, + stdout: error.stdout, + stderr: error.stderr, + }); + } +}; + +const checkQuery = async (resolverIp, name, expectedStatus, requireAnswer, runner, timeoutMs) => { + let response; + try { + response = normalizeResponse(await runner(resolverIp, name, { timeoutMs })); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error), status: undefined, answerCount: undefined }; + } + if (response.code !== 0) { + return { ok: false, reason: responseFailure(response), status: undefined, answerCount: undefined }; + } + return parseDigResponse(response.stdout, { expectedStatus, requireAnswer }); +}; + +const checkResolverAttempt = async (resolver, runner, options) => { + const positive = await checkQuery( + resolver.udp, + options.queryName, + 'NOERROR', + true, + runner, + options.timeoutMs, + ); + if (!positive.ok) return { ok: false, phase: 'recursive answer', reason: positive.reason }; + + const negative = await checkQuery( + resolver.udp, + options.nxdomainName, + 'NXDOMAIN', + false, + runner, + options.timeoutMs, + ); + if (!negative.ok) return { ok: false, phase: 'NXDOMAIN check', reason: negative.reason }; + + return { ok: true }; +}; + +/** Check one resolver, retrying transient failures before reporting it down. */ +export const checkResolver = async (resolver, { + attempts = DEFAULT_ATTEMPTS, + queryName = DEFAULT_QUERY_NAME, + nxdomainName = DEFAULT_NXDOMAIN_NAME, + timeoutMs = DEFAULT_TIMEOUT_MS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + runner = runDig, + wait = sleep, +} = {}) => { + const normalizedAttempts = Number.isFinite(attempts) + ? Math.max(1, Math.trunc(attempts)) + : DEFAULT_ATTEMPTS; + const failures = []; + + for (let attempt = 1; attempt <= normalizedAttempts; attempt += 1) { + const result = await checkResolverAttempt(resolver, runner, { + queryName, + nxdomainName, + timeoutMs, + }); + if (result.ok) { + return { + id: resolver.id, + name: resolver.name, + country: resolver.country, + udp: resolver.udp, + ok: true, + attempts: attempt, + }; + } + + failures.push({ attempt, ...result }); + if (attempt < normalizedAttempts) await wait(retryDelayMs); + } + + const lastFailure = failures.at(-1); + return { + id: resolver.id, + name: resolver.name, + country: resolver.country, + udp: resolver.udp, + ok: false, + attempts: normalizedAttempts, + phase: lastFailure.phase, + reason: lastFailure.reason, + failures, + }; +}; + +/** Check every UDP entry concurrently and retain the DoH-only count for reporting. */ +export const checkDnsResolvers = async (resolvers = DNS_RESOLVERS, options = {}) => { + const udpResolvers = resolvers.filter((resolver) => resolver.udp); + const results = await Promise.all(udpResolvers.map((resolver) => checkResolver(resolver, options))); + const failed = results.filter((result) => !result.ok); + + return { + checkedAt: (options.now ?? (() => new Date().toISOString()))(), + ok: failed.length === 0, + checked: results.length, + passed: results.length - failed.length, + failed: failed.length, + dohOnly: resolvers.filter((resolver) => !resolver.udp).length, + resolvers: results, + }; +}; + +const escapeMarkdown = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' '); + +export const formatMarkdown = (report) => { + const lines = [ + '## DNS resolver health', + '', + `Checked ${report.checkedAt}: **${report.passed}/${report.checked} UDP resolvers passed**.`, + '', + '| Status | Resolver | UDP | Details |', + '| --- | --- | --- | --- |', + ]; + + for (const resolver of report.resolvers) { + const status = resolver.ok ? 'PASS' : 'FAIL'; + const details = resolver.ok + ? `passed after ${resolver.attempts} attempt${resolver.attempts === 1 ? '' : 's'}` + : `${resolver.phase}: ${resolver.reason} (after ${resolver.attempts} attempts)`; + lines.push(`| ${status} | ${escapeMarkdown(resolver.name)} (${resolver.country}) | \`${resolver.udp}\` | ${escapeMarkdown(details)} |`); + } + + if (report.dohOnly > 0) { + lines.push('', `_${report.dohOnly} DoH-only entr${report.dohOnly === 1 ? 'y was' : 'ies were'} not checked by this UDP probe._`); + } + return lines.join('\n'); +}; + +const main = async () => { + const options = parseCliArgs(process.argv.slice(2)); + await ensureDigAvailable(); + const report = await checkDnsResolvers(DNS_RESOLVERS, { attempts: options.attempts }); + const output = options.json ? { ...report, markdown: formatMarkdown(report) } : formatMarkdown(report); + process.stdout.write(`${options.json ? JSON.stringify(output, null, 2) : output}\n`); + if (!report.ok) process.exitCode = 1; +}; + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 2; + }); +} diff --git a/tests/api-handlers.test.js b/tests/api-handlers.test.js index 657f0b4f8..fd10a4dd3 100644 --- a/tests/api-handlers.test.js +++ b/tests/api-handlers.test.js @@ -226,27 +226,6 @@ describe('dns-resolver handler', () => { assert.equal(res.statusCode, 405); assert.deepEqual(res.body, { message: 'Method Not Allowed' }); }); - - it('rejects missing and non-string hostname', async () => { - const missing = createResponse(); - await dnsResolverHandler(createRequest(), missing); - assert.equal(missing.statusCode, 400); - assert.deepEqual(missing.body, { error: 'Hostname parameter must be a string' }); - - const numeric = createResponse(); - // Callers sometimes pass non-string via programmatic access; Express - // itself would stringify query, but we guard defensively. - await dnsResolverHandler(createRequest({ query: { hostname: 12345, type: 'A' } }), numeric); - assert.equal(numeric.statusCode, 400); - assert.deepEqual(numeric.body, { error: 'Hostname parameter must be a string' }); - }); - - it("rejects hostname that doesn't contain a dot", async () => { - const res = createResponse(); - await dnsResolverHandler(createRequest({ query: { hostname: 'localhost', type: 'A' } }), res); - assert.equal(res.statusCode, 400); - assert.deepEqual(res.body, { error: 'Invalid hostname' }); - }); }); // -- get-whois handler ---------------------------------------------------- diff --git a/tests/check-dns-resolvers.test.js b/tests/check-dns-resolvers.test.js new file mode 100644 index 000000000..4455744fc --- /dev/null +++ b/tests/check-dns-resolvers.test.js @@ -0,0 +1,148 @@ +// Unit tests for the DNS resolver health probe; all dig calls are mocked. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + checkDnsResolvers, + checkResolver, + ensureDigAvailable, + formatMarkdown, + parseDigResponse, +} from '../scripts/check-dns-resolvers.js'; + +const positiveResponse = (answerCount = 2, flags = 'qr rd ra') => [ + `;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1`, + `;; flags: ${flags}; QUERY: 1, ANSWER: ${answerCount}, AUTHORITY: 0, ADDITIONAL: 1`, +].join('\n'); + +const nxdomainResponse = (flags = 'qr rd ra') => [ + ';; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 2', + `;; flags: ${flags}; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1`, +].join('\n'); + +const resolver = { id: 'test', name: 'Test Resolver', country: 'ZZ', udp: '192.0.2.1' }; + +describe('ensureDigAvailable', () => { + it('reports one actionable diagnostic when dig is missing', async () => { + await assert.rejects( + () => ensureDigAvailable(async () => { + const error = new Error('spawn dig ENOENT'); + error.code = 'ENOENT'; + throw error; + }), + /The `dig` executable is required.*dnsutils.*bind/, + ); + }); +}); + +describe('parseDigResponse', () => { + it('accepts a recursive NOERROR response with an answer', () => { + assert.deepEqual(parseDigResponse(positiveResponse(), { expectedStatus: 'NOERROR', requireAnswer: true }), { + ok: true, + status: 'NOERROR', + flags: ['qr', 'rd', 'ra'], + answerCount: 2, + }); + }); + + it('accepts a recursive NXDOMAIN response with no answers', () => { + assert.equal(parseDigResponse(nxdomainResponse(), { expectedStatus: 'NXDOMAIN', requireAnswer: false }).ok, true); + }); + + it('rejects a response without recursion available', () => { + const result = parseDigResponse(positiveResponse(1, 'qr rd'), { expectedStatus: 'NOERROR', requireAnswer: true }); + assert.equal(result.ok, false); + assert.match(result.reason, /ra flag/); + }); + + it('rejects a NOERROR response with no answer', () => { + const result = parseDigResponse(positiveResponse(0), { expectedStatus: 'NOERROR', requireAnswer: true }); + assert.equal(result.ok, false); + assert.match(result.reason, /no answers/); + }); + + it('rejects an NXDOMAIN response that contains answers', () => { + const result = parseDigResponse(nxdomainResponse().replace('ANSWER: 0', 'ANSWER: 1'), { + expectedStatus: 'NXDOMAIN', + requireAnswer: false, + }); + assert.equal(result.ok, false); + assert.match(result.reason, /answers/); + }); +}); + +describe('checkResolver', () => { + it('retries a failed attempt and passes when the resolver recovers', async () => { + let calls = 0; + const runner = async () => { + calls += 1; + if (calls === 1) return { code: 1, stderr: ';; communications error' }; + return { code: 0, stdout: calls === 2 ? positiveResponse() : nxdomainResponse() }; + }; + + const result = await checkResolver(resolver, { attempts: 2, retryDelayMs: 0, runner, wait: async () => {} }); + assert.equal(result.ok, true); + assert.equal(result.attempts, 2); + assert.equal(calls, 3); + }); + + it('reports the final failure after all attempts', async () => { + const result = await checkResolver(resolver, { + attempts: 2, + retryDelayMs: 0, + runner: async () => ({ code: 1, stderr: 'timeout' }), + wait: async () => {}, + }); + assert.equal(result.ok, false); + assert.equal(result.attempts, 2); + assert.equal(result.failures.length, 2); + assert.equal(result.reason, 'timeout'); + }); + + it('fails when the resolver rewrites the NXDOMAIN probe', async () => { + const result = await checkResolver(resolver, { + attempts: 1, + runner: async (_ip, name) => ({ + code: 0, + stdout: name === 'example.com' ? positiveResponse() : positiveResponse(1), + }), + }); + assert.equal(result.ok, false); + assert.equal(result.phase, 'NXDOMAIN check'); + assert.match(result.reason, /expected NXDOMAIN, got NOERROR/); + }); + + it('turns a thrown runner error into a failed resolver result', async () => { + const result = await checkResolver(resolver, { + attempts: 1, + runner: async () => { + throw new Error('network unavailable'); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'network unavailable'); + }); +}); + +describe('checkDnsResolvers and formatMarkdown', () => { + it('checks UDP entries and reports DoH-only entries as skipped', async () => { + const report = await checkDnsResolvers([ + resolver, + { id: 'doh', name: 'DoH only', country: 'US', doh: 'https://example.test/resolve?' }, + ], { + attempts: 1, + runner: async (_ip, name) => ({ code: 0, stdout: name === 'example.com' ? positiveResponse() : nxdomainResponse() }), + now: () => '2026-08-24T00:00:00.000Z', + }); + + assert.deepEqual({ ok: report.ok, checked: report.checked, passed: report.passed, failed: report.failed, dohOnly: report.dohOnly }, { + ok: true, + checked: 1, + passed: 1, + failed: 0, + dohOnly: 1, + }); + assert.match(formatMarkdown(report), /DoH-only entry was not checked/); + }); +}); diff --git a/tests/dns-record-types.test.js b/tests/dns-record-types.test.js new file mode 100644 index 000000000..7b7f409c0 --- /dev/null +++ b/tests/dns-record-types.test.js @@ -0,0 +1,32 @@ +// Guards the one list behind the record-type picker, the requireValidRecordType +// allowlist and the resolver switch — including that the frontend bridge has +// not regrown its own copy. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { DNS_RECORD_TYPES, DNS_RECORD_TYPE_SET, NAME_VALUED_TYPES } from '../common/dns-record-types.js'; +import { DNS_RECORD_TYPES as bridgedTypes } from '../frontend/utils/dns-record-types.js'; + +describe('DNS record types', () => { + it('re-exports the same list through the frontend bridge', () => { + assert.deepEqual(bridgedTypes, DNS_RECORD_TYPES); + }); + + it('keeps the lookup set in step with the ordered list', () => { + assert.deepEqual([...DNS_RECORD_TYPE_SET].sort(), [...DNS_RECORD_TYPES].sort()); + }); + + it('only marks types the resolver actually answers for as name-valued', () => { + // A name-valued type missing from the list would never reach + // withRootDot, so the drift would show up as punctuation noise in the + // results table rather than as an error. + for (const type of NAME_VALUED_TYPES) { + assert.ok(DNS_RECORD_TYPE_SET.has(type), `${type} is not a supported record type`); + } + }); + + it('holds uppercase types, which is the form the guard normalizes to', () => { + for (const type of DNS_RECORD_TYPES) assert.equal(type, type.toUpperCase()); + }); +}); diff --git a/tests/dns-resolver-formatters.test.js b/tests/dns-resolver-formatters.test.js new file mode 100644 index 000000000..e2368b615 --- /dev/null +++ b/tests/dns-resolver-formatters.test.js @@ -0,0 +1,126 @@ +// Offline coverage for turning each transport's raw DNS answer into the one +// display string a result row shows — Node's Resolver objects and the DoH JSON +// envelope alike. No test performs a real lookup. + +import assert from 'node:assert/strict'; +import { Resolver } from 'node:dns'; +import { afterEach, describe, it } from 'node:test'; + +import { dohRecords, formatCaaRecords, formatSoaRecord, resolveDns, resolveDoh, withRootDot } from '../api/dns-resolver.js'; + +const originalResolveCname = Resolver.prototype.resolveCname; +const originalFetch = globalThis.fetch; + +afterEach(() => { + Resolver.prototype.resolveCname = originalResolveCname; + globalThis.fetch = originalFetch; +}); + +describe('DNS resolver record formatting', () => { + it('formats an SOA object in DNS presentation order, with the root dots the DoH path returns', () => { + assert.equal(formatSoaRecord({ + nsname: 'ns1.example.com', + hostmaster: 'hostmaster.example.com', + serial: 2026082301, + refresh: 3600, + retry: 600, + expire: 1209600, + minttl: 300, + }), 'ns1.example.com. hostmaster.example.com. 2026082301 3600 600 1209600 300'); + }); + + it('formats standard and provider-specific CAA tags from each record shape', () => { + assert.equal(formatCaaRecords([ + { critical: 0, type: 'CAA', issue: 'letsencrypt.org' }, + { critical: 128, type: 'CAA', issuewild: ';' }, + { critical: 0, type: 'CAA', iodef: 'mailto:security@example.com' }, + { critical: 1, type: 'CAA', customprovider: 'ca.example' }, + ]), [ + '0 issue "letsencrypt.org"', + '128 issuewild ";"', + '0 iodef "mailto:security@example.com"', + '1 customprovider "ca.example"', + ].join(', ')); + }); + + it('drops a CAA record carrying no tag rather than rendering an undefined one', () => { + assert.equal(formatCaaRecords([ + { critical: 0, type: 'CAA' }, + { critical: 0, type: 'CAA', issue: 'ca.example' }, + ]), '0 issue "ca.example"'); + }); +}); + +describe('DoH answer selection', () => { + it('prefers the answer section', () => { + const data = { + Answer: [{ type: 1, data: '192.0.2.1' }], + Authority: [{ type: 6, data: 'ns1.example.com. hostmaster.example.com. 1 2 3 4 5' }], + }; + assert.deepEqual(dohRecords(data, 'A'), data.Answer); + }); + + it('skips a CNAME answer and takes the authority SOA for a SOA query on an aliased name', () => { + const soa = { type: 6, data: 'ns1.example.com. hostmaster.example.com. 1 2 3 4 5' }; + assert.deepEqual(dohRecords({ + Answer: [{ type: 5, data: 'target.example.net.' }], + Authority: [soa], + }, 'SOA'), [soa]); + }); + + it('keeps a SOA answer at the zone apex', () => { + const soa = { type: 6, data: 'ns1.example.com. hostmaster.example.com. 1 2 3 4 5' }; + assert.deepEqual(dohRecords({ Answer: [soa] }, 'SOA'), [soa]); + }); + + it('falls back to the authority SOA for a name below the zone apex', () => { + const soa = { type: 6, data: 'ns1.example.com. hostmaster.example.com. 1 2 3 4 5' }; + assert.deepEqual(dohRecords({ Authority: [{ type: 2, data: 'ns1.example.com.' }, soa] }, 'SOA'), [soa]); + }); + + it('leaves the authority section alone for every other record type', () => { + const data = { Authority: [{ type: 6, data: 'ns1.example.com. hostmaster.example.com. 1 2 3 4 5' }] }; + assert.deepEqual(dohRecords(data, 'A'), []); + assert.deepEqual(dohRecords(data, 'MX'), []); + }); + + it('reports nothing when neither section carries an answer', () => { + assert.deepEqual(dohRecords({}, 'SOA'), []); + assert.deepEqual(dohRecords({ Answer: [] }, 'A'), []); + }); +}); + +describe('root-dot normalization', () => { + it('adds the dot only when it is missing', () => { + assert.equal(withRootDot('dns.google'), 'dns.google.'); + assert.equal(withRootDot('dns.google.'), 'dns.google.'); + }); + + it('normalizes the UDP side, which Node returns bare', async () => { + Resolver.prototype.resolveCname = (_hostname, callback) => callback(null, ['github.com']); + assert.deepEqual( + await resolveDns('www.github.com', 'CNAME', 'Example DNS', '192.0.2.1'), + ['github.com.'], + ); + }); + + it('normalizes the DoH side, whichever way the endpoint writes it', async () => { + globalThis.fetch = async () => new Response(JSON.stringify({ + Answer: [{ type: 5, data: 'github.com' }, { type: 5, data: 'other.example.' }], + }), { status: 200 }); + assert.deepEqual( + await resolveDoh('www.github.com', 'CNAME', 'Example DoH', 'https://doh.example.test/resolve?'), + ['github.com.', 'other.example.'], + ); + }); + + it('leaves address- and text-valued answers untouched', async () => { + globalThis.fetch = async () => new Response(JSON.stringify({ + Answer: [{ type: 1, data: '192.0.2.1' }], + }), { status: 200 }); + assert.deepEqual( + await resolveDoh('example.com', 'A', 'Example DoH', 'https://doh.example.test/resolve?'), + ['192.0.2.1'], + ); + }); +}); diff --git a/tests/dns-resolver-logging.test.js b/tests/dns-resolver-logging.test.js new file mode 100644 index 000000000..763c11251 --- /dev/null +++ b/tests/dns-resolver-logging.test.js @@ -0,0 +1,83 @@ +// Verifies DNS resolver telemetry without contacting real upstream servers. + +import assert from 'node:assert/strict'; +import { Resolver } from 'node:dns'; +import { afterEach, describe, it } from 'node:test'; + +import { resolveDns, resolveDoh } from '../api/dns-resolver.js'; +import logger from '../common/logger.js'; + +const originalResolve4 = Resolver.prototype.resolve4; +const originalFetch = globalThis.fetch; +const originalWarn = logger.warn; +const originalDebug = logger.debug; + +afterEach(() => { + Resolver.prototype.resolve4 = originalResolve4; + globalThis.fetch = originalFetch; + logger.warn = originalWarn; + logger.debug = originalDebug; +}); + +describe('DNS resolver logging', () => { + it('promotes availability errors to warn without logging the hostname', async () => { + const warnCalls = []; + const debugCalls = []; + logger.warn = (...args) => warnCalls.push(args); + logger.debug = (...args) => debugCalls.push(args); + Resolver.prototype.resolve4 = (_hostname, callback) => { + const error = new Error('resolver timed out'); + error.code = 'ETIMEOUT'; + callback(error); + }; + + assert.equal(await resolveDns('private.example.test', 'A', 'Example DNS', '192.0.2.1'), 'N/A'); + assert.equal(warnCalls.length, 1); + assert.equal(debugCalls.length, 0); + assert.equal(warnCalls[0][0].server, '192.0.2.1'); + assert.equal(warnCalls[0][0].provider, 'Example DNS'); + assert.equal(warnCalls[0][0].code, 'ETIMEOUT'); + assert.equal('private.example.test' in warnCalls[0][0], false); + }); + + it('keeps non-availability UDP failures at debug level', async () => { + const warnCalls = []; + const debugCalls = []; + logger.warn = (...args) => warnCalls.push(args); + logger.debug = (...args) => debugCalls.push(args); + Resolver.prototype.resolve4 = (_hostname, callback) => { + const error = new Error('missing record'); + error.code = 'ENOTFOUND'; + callback(error); + }; + + assert.equal(await resolveDns('private.example.test', 'A', 'Example DNS', '192.0.2.1'), 'N/A'); + assert.equal(warnCalls.length, 0); + assert.equal(debugCalls.length, 1); + assert.equal(debugCalls[0][0].server, '192.0.2.1'); + assert.equal(debugCalls[0][0].code, 'ENOTFOUND'); + }); + + it('warns on non-2xx DoH responses', async () => { + const warnCalls = []; + logger.warn = (...args) => warnCalls.push(args); + logger.debug = () => assert.fail('non-2xx DoH response should not be debug-only'); + globalThis.fetch = async () => new Response('', { status: 503 }); + + assert.equal(await resolveDoh('private.example.test', 'A', 'Example DoH', 'https://doh.example.test/resolve?'), 'N/A'); + assert.equal(warnCalls.length, 1); + assert.equal(warnCalls[0][0].server, 'Example DoH'); + assert.equal(warnCalls[0][0].code, 503); + }); + + it('warns on DoH transport and parsing failures', async () => { + const warnCalls = []; + logger.warn = (...args) => warnCalls.push(args); + globalThis.fetch = async () => { throw new Error('socket reset'); }; + + assert.equal(await resolveDoh('private.example.test', 'A', 'Example DoH', 'https://doh.example.test/resolve?'), 'N/A'); + assert.equal(warnCalls.length, 1); + assert.equal(warnCalls[0][0].server, 'Example DoH'); + assert.match(warnCalls[0][0].err.message, /socket reset/); + }); +}); diff --git a/tests/guards.test.js b/tests/guards.test.js index 927d905f4..9316cb561 100644 --- a/tests/guards.test.js +++ b/tests/guards.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { requireReferer, requirePublicIP, requireValidPrefix, requireValidDomain, requireValidProviderId, requireValidReportId } from '../common/guards.js'; +import { requireReferer, requirePublicIP, requireValidPrefix, requireValidDomain, requireValidProviderId, requireValidRecordType, requireValidReportId } from '../common/guards.js'; // Minimal (req, res, next) stubs — just enough to observe what the // middleware does. @@ -205,6 +205,42 @@ describe('requireValidReportId', () => { }); }); +describe('requireValidRecordType', () => { + const guard = requireValidRecordType(); + + it('calls next() for a supported type', () => { + let nextCalled = false; + guard(makeReq({ query: { type: 'CAA' } }), makeRes(), () => { nextCalled = true; }); + assert.equal(nextCalled, true); + }); + + it('uppercases the type in place so the handler switch matches', () => { + const req = makeReq({ query: { type: 'caa' } }); + guard(req, makeRes(), () => {}); + assert.equal(req.query.type, 'CAA'); + }); + + it('rejects a missing type', () => { + const res = makeRes(); + let nextCalled = false; + guard(makeReq({ query: {} }), res, () => { nextCalled = true; }); + assert.equal(nextCalled, false); + assert.equal(res.statusCode, 400); + assert.deepEqual(res.body, { error: 'No record type provided' }); + }); + + it('rejects a type the resolver does not handle, so DoH never forwards it', () => { + for (const type of ['ANY', 'DNSKEY', 'HTTPS', 'PTR', '../etc']) { + const res = makeRes(); + let nextCalled = false; + guard(makeReq({ query: { type } }), res, () => { nextCalled = true; }); + assert.equal(nextCalled, false, type); + assert.equal(res.statusCode, 400); + assert.deepEqual(res.body, { error: 'Invalid record type' }); + } + }); +}); + describe('requireValidDomain', () => { const guard = requireValidDomain(); @@ -222,6 +258,15 @@ describe('requireValidDomain', () => { assert.equal(req.query.domain, 'www.example.com'); }); + it('supports a custom query parameter name', () => { + const hostnameGuard = requireValidDomain('hostname'); + const req = makeReq({ query: { hostname: 'WWW.Example.COM' } }); + let nextCalled = false; + hostnameGuard(req, makeRes(), () => { nextCalled = true; }); + assert.equal(nextCalled, true); + assert.equal(req.query.hostname, 'www.example.com'); + }); + it('returns 400 when the domain is missing', () => { const res = makeRes(); let nextCalled = false; diff --git a/tests/valid-ip.test.js b/tests/valid-ip.test.js index 5595dd101..7c1bb40ec 100644 --- a/tests/valid-ip.test.js +++ b/tests/valid-ip.test.js @@ -48,6 +48,9 @@ const validDomains = [ 'EXAMPLE.COM', 'xn--n3h.example', 'with-hyphen.io', + '_dmarc.example.com', // RFC 8552 underscored service names — + '_xmpp-server._tcp.example.com', // DnsResolver has to be able to reach + 'selector1._domainkey.example.com', // DMARC / DKIM / SRV records ]; const invalidDomains = [ @@ -56,7 +59,9 @@ const invalidDomains = [ '.example.com', 'example.', 'example..com', - 'has_underscore.com', + 'has_underscore.com', // underscore is a label prefix only, not mid-label + 'example._com', // ...and never in the TLD + '_.com', // ...and never the whole label 'trailing.dot.', 'one.1', // TLD must be 2+ letters, not digits '192.168.1.1', // numeric-only TLD is rejected