From 7f8051bdc7ac5941667aa81a3a33f8463ad6a6d0 Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:34:29 +0800 Subject: [PATCH 01/20] Feat(dns): add HiNet and GIGA (Taiwan) resolvers Replace the since-discontinued Quad101 with two verified Taiwan resolvers (issue #391 was rescoped to reachable public resolvers): - HiNet (Chunghwa Telecom) 168.95.1.1 - GIGA (Taiwan Fixed Network) 203.133.1.6 Both are UDP-only (no JSON DoH endpoint). Also add a contributor rule to the data-file header requiring that a resolver actually answer with the ra flag before a PR, so future additions verify reachability instead of trusting published docs. Co-authored-by: OpenAI Codex --- api/data/dns-resolvers.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/data/dns-resolvers.js b/api/data/dns-resolvers.js index 2ffab8468..7e63e8ae0 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. @@ -41,5 +46,7 @@ export const DNS_RESOLVERS = [ { 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' }, ]; From cbc562a33233ade03ef809ba082048100b349880 Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:05:56 +0800 Subject: [PATCH 02/20] Feat(dns): add CZ.NIC ODVR (Czechia) resolver Retarget the rescoped Europe-resolver issue (#392) to CZ.NIC's ODVR (193.17.47.1), a verified reachable European public resolver run by the .cz registry. UDP-only: its DoH endpoint does not serve the JSON API. Co-authored-by: OpenAI Codex --- api/data/dns-resolvers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/data/dns-resolvers.js b/api/data/dns-resolvers.js index 2ffab8468..40ff91dbe 100644 --- a/api/data/dns-resolvers.js +++ b/api/data/dns-resolvers.js @@ -42,4 +42,5 @@ export const DNS_RESOLVERS = [ { 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' }, + { id: 'cznic', name: 'CZ.NIC ODVR', country: 'CZ', udp: '193.17.47.1' }, ]; From 8bb9dc777b8d879733599d6258e9b17de22c4787 Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:35:01 +0800 Subject: [PATCH 03/20] Feat(dns): add Yandex.DNS (Russia) resolver Add Yandex.DNS (77.88.8.8), a reachable Russian public resolver, to the curated list. UDP-only: Yandex's DoH endpoint speaks RFC 8484 wire format rather than the application/dns-json API this endpoint queries. Co-authored-by: OpenAI Codex --- api/data/dns-resolvers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/data/dns-resolvers.js b/api/data/dns-resolvers.js index 2ffab8468..1ef2275c4 100644 --- a/api/data/dns-resolvers.js +++ b/api/data/dns-resolvers.js @@ -38,6 +38,7 @@ 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' }, From bb8caa6cc2687c2017447950973be430788079b0 Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:13:47 +0800 Subject: [PATCH 04/20] Chore(dns): add resolver health checks Run a daily and dev-push probe for every curated UDP resolver, checking recursive answers and honest NXDOMAIN responses. Add mocked unit coverage and publish a machine-readable report in the Actions summary. Co-authored-by: OpenAI Codex --- .github/workflows/dns-resolvers-health.yml | 50 ++++ .gitignore | 1 + scripts/check-dns-resolvers.js | 251 +++++++++++++++++++++ tests/check-dns-resolvers.test.js | 134 +++++++++++ 4 files changed, 436 insertions(+) create mode 100644 .github/workflows/dns-resolvers-health.yml create mode 100644 scripts/check-dns-resolvers.js create mode 100644 tests/check-dns-resolvers.test.js diff --git a/.github/workflows/dns-resolvers-health.yml b/.github/workflows/dns-resolvers-health.yml new file mode 100644 index 000000000..0223893bc --- /dev/null +++ b/.github/workflows/dns-resolvers-health.yml @@ -0,0 +1,50 @@ +# Run a scheduled external check so stale or restricted public resolvers are +# noticed before a contributor is sent after an unusable endpoint. +name: DNS Resolver Health + +on: + push: + branches: [dev] + schedule: + - cron: "17 3 * * *" # daily at 03:17 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + health: + name: Check public UDP resolvers + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install dig + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y dnsutils + + - name: Probe resolvers + id: probe + shell: bash + run: | + set +e + node scripts/check-dns-resolvers.js --json > dns-resolver-health.json + status=$? + jq -r '.markdown' dns-resolver-health.json | tee -a "$GITHUB_STEP_SUMMARY" + echo "Probe exit status: $status" + exit "$status" + + - name: Upload probe report + if: always() + uses: actions/upload-artifact@v4 + with: + name: dns-resolver-health-${{ github.run_id }} + path: dns-resolver-health.json + if-no-files-found: error + retention-days: 14 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/scripts/check-dns-resolvers.js b/scripts/check-dns-resolvers.js new file mode 100644 index 000000000..55dfa4130 --- /dev/null +++ b/scripts/check-dns-resolvers.js @@ -0,0 +1,251 @@ +// 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)); + +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)); + 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/check-dns-resolvers.test.js b/tests/check-dns-resolvers.test.js new file mode 100644 index 000000000..6722a9355 --- /dev/null +++ b/tests/check-dns-resolvers.test.js @@ -0,0 +1,134 @@ +// 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, + 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('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/); + }); +}); From 624656bd3289dab2ec0113c16f6d28a1b410ab90 Mon Sep 17 00:00:00 2001 From: Victor Solano Date: Mon, 24 Aug 2026 09:49:11 +0200 Subject: [PATCH 05/20] Feat(dnsresolver): support SOA and CAA record lookups Add SOA and CAA to the DNS resolver's record types, resolved through the existing backend switch. The DoH path is unchanged. SOA is formatted in DNS presentation order. CAA property tags are derived from each record's own key rather than a fixed whitelist, so a provider-specific tag renders as itself instead of collapsing to a placeholder. Node's Resolver returns `critical` and `type` as metadata alongside that one extensible tag, so both are excluded when picking it. Eight record types no longer fit one row on a phone, where the control previously pushed past its parent. The selector is now a four-column grid on narrow viewports and stays a single row from `md` up. Formatting is exported and covered offline by unit tests, including a provider-specific CAA tag. No test performs a real DNS lookup. Closes #400 Co-Authored-By: Claude --- api/dns-resolver.js | 28 ++++++++++++++- .../components/advanced-tools/DnsResolver.vue | 5 +-- tests/dns-resolver-formatters.test.js | 34 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 tests/dns-resolver-formatters.test.js diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 7b188e87d..855089290 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -14,8 +14,26 @@ import { DNS_RESOLVERS } from './data/dns-resolvers.js'; const DNS_TIMEOUT_MS = 3000; const DOH_TIMEOUT_MS = 5000; +export const formatSoaRecord = (record) => [ + record.nsname, + record.hostmaster, + record.serial, + record.refresh, + record.retry, + record.expire, + record.minttl, +].join(' '); + +// Node includes these metadata fields alongside one extensible property tag. +const CAA_META_KEYS = new Set(['critical', 'type']); + +export const formatCaaRecords = (records) => records.map((record) => { + const [tag, value] = Object.entries(record).find(([key]) => !CAA_META_KEYS.has(key)); + return `${record.critical} ${tag} ${JSON.stringify(value)}`; +}).join(', '); + // 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 +43,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,6 +72,12 @@ 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'); } diff --git a/frontend/components/advanced-tools/DnsResolver.vue b/frontend/components/advanced-tools/DnsResolver.vue index cb6f32bcd..e461a5319 100644 --- a/frontend/components/advanced-tools/DnsResolver.vue +++ b/frontend/components/advanced-tools/DnsResolver.vue @@ -7,10 +7,11 @@
- +
{{ t('dnsresolver.Record') }}: @@ -118,7 +119,7 @@ const errorMsg = ref(''); const combinedResults = ref([]); const countryFilter = ref('all'); -const recordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT']; +const recordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'CAA']; const validateInput = (input) => { if (!input.match(/^https?:\/\//)) input = 'http://' + input; diff --git a/tests/dns-resolver-formatters.test.js b/tests/dns-resolver-formatters.test.js new file mode 100644 index 000000000..8f84fe4c1 --- /dev/null +++ b/tests/dns-resolver-formatters.test.js @@ -0,0 +1,34 @@ +// Offline unit coverage for DNS record shapes returned by Node's Resolver. + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { formatCaaRecords, formatSoaRecord } from '../api/dns-resolver.js'; + +describe('DNS resolver record formatting', () => { + it('formats an SOA object in DNS presentation order', () => { + 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(', ')); + }); +}); From f3a0d92a72b022dd4db4124ca511802383540515 Mon Sep 17 00:00:00 2001 From: akidsfree Date: Mon, 24 Aug 2026 16:06:11 +0800 Subject: [PATCH 06/20] Feat(i18n): add Brazilian Portuguese privacy policy Completes the 53-key privacy dataset for pt-BR. Co-authored-by: Codex --- frontend/locales/privacy/pt-BR.json | 111 ++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 frontend/locales/privacy/pt-BR.json diff --git a/frontend/locales/privacy/pt-BR.json b/frontend/locales/privacy/pt-BR.json new file mode 100644 index 000000000..060550862 --- /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 terceiros. 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." + ] + } + } + } +} From 1611c6b701279d4bb1b71a4eeef12dba44e9383e Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 16:16:36 +0800 Subject: [PATCH 07/20] Refactor(dns): centralize hostname validation Route DNS resolver requests through the shared domain guard so hostname presence, syntax, and canonical lowercasing follow the same contract as other domain-taking APIs. Co-Authored-By: GPT-5 --- api/dns-resolver.js | 13 +------------ backend-server.js | 2 +- tests/api-handlers.test.js | 21 --------------------- tests/guards.test.js | 9 +++++++++ 4 files changed, 11 insertions(+), 34 deletions(-) diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 7b188e87d..761424b5e 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -98,20 +98,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 diff --git a/backend-server.js b/backend-server.js index dba20bc01..fef275d74 100644 --- a/backend-server.js +++ b/backend-server.js @@ -275,7 +275,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'), dnsResolver); app.get('/api/dnsleaktest/session/:token', dnsLeakGetResult); app.get('/api/invisibility', invisibilitytestHandler); app.get('/api/getuserinfo', getUserinfo); 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/guards.test.js b/tests/guards.test.js index 927d905f4..51bd42b70 100644 --- a/tests/guards.test.js +++ b/tests/guards.test.js @@ -222,6 +222,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; From 3543f6920bbd6d0d9569ead31a3295c2a8585697 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 16:33:38 +0800 Subject: [PATCH 08/20] Fix(i18n): correct pt-BR privacy wording Align the telemetry disclosure with the English source so the named Sentry recipient does not contradict a broader claim that data is never shared with third parties. Co-Authored-By: GPT-5 --- frontend/locales/privacy/pt-BR.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/locales/privacy/pt-BR.json b/frontend/locales/privacy/pt-BR.json index 060550862..62ff9b08c 100644 --- a/frontend/locales/privacy/pt-BR.json +++ b/frontend/locales/privacy/pt-BR.json @@ -72,7 +72,7 @@ "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 terceiros. Ela inclui:" + "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.", From ffa5a0715ce769050d77d706066c1c21bfbf5a10 Mon Sep 17 00:00:00 2001 From: Victor Solano Date: Mon, 24 Aug 2026 10:34:47 +0200 Subject: [PATCH 09/20] Fix(dnsresolver): recover a CAA tag named like a metadata field RFC 8659 allows any alphanumeric property tag, `type` and `critical` included. Node writes the tag onto the record under its own name, so such a tag overwrites the metadata field of the same name and the scan for a non-metadata key finds nothing. Destructuring undefined then threw, and the surrounding handler turned an otherwise valid answer into N/A for that resolver. The tag is now recovered from whichever field stopped holding a metadata value: `type` is otherwise always the constant 'CAA', and `critical` is otherwise always a number. A tag named `critical` displaces the flag itself, which Node has already lost by that point, so it falls back to 0. Reported by review on #442. The suggested fix there was to stop excluding `type`, which would break every ordinary record: a live google.com answer is `{critical: 0, type: 'CAA', issue: 'pki.goog'}`, and without the exclusion the scan picks `type` and renders `0 type "CAA"` instead of `0 issue "pki.goog"`. Co-Authored-By: Claude --- api/dns-resolver.js | 21 ++++++++++++++++++--- tests/dns-resolver-formatters.test.js | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 855089290..4ce2737fd 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -24,12 +24,27 @@ export const formatSoaRecord = (record) => [ record.minttl, ].join(' '); -// Node includes these metadata fields alongside one extensible property tag. +// Node reports `critical` plus a constant `type: 'CAA'` as metadata alongside +// the record's one extensible property tag. const CAA_META_KEYS = new Set(['critical', 'type']); +// RFC 8659 allows any alphanumeric tag, including one named `type` or +// `critical`. Node writes the tag onto the record under its own name, so such a +// tag overwrites the metadata field of the same name and no other key is left +// to find. Recover it from whichever field stopped holding a metadata value: +// `type` is always the constant 'CAA', and `critical` is always a number. +const caaTagEntry = (record) => { + const tagged = Object.entries(record).find(([key]) => !CAA_META_KEYS.has(key)); + if (tagged) return tagged; + if (record.type !== 'CAA') return ['type', record.type]; + return ['critical', record.critical]; +}; + export const formatCaaRecords = (records) => records.map((record) => { - const [tag, value] = Object.entries(record).find(([key]) => !CAA_META_KEYS.has(key)); - return `${record.critical} ${tag} ${JSON.stringify(value)}`; + const [tag, value] = caaTagEntry(record); + // A tag named `critical` displaces the flag itself; it is unrecoverable. + const critical = typeof record.critical === 'number' ? record.critical : 0; + return `${critical} ${tag} ${JSON.stringify(value)}`; }).join(', '); // Resolve via classic UDP DNS. Returns the raw result value: an array of diff --git a/tests/dns-resolver-formatters.test.js b/tests/dns-resolver-formatters.test.js index 8f84fe4c1..04de67755 100644 --- a/tests/dns-resolver-formatters.test.js +++ b/tests/dns-resolver-formatters.test.js @@ -18,6 +18,20 @@ describe('DNS resolver record formatting', () => { }), 'ns1.example.com hostmaster.example.com 2026082301 3600 600 1209600 300'); }); + it('recovers a tag whose name collides with Node\'s metadata fields', () => { + // Node writes the tag onto the record under its own name, so a record + // tagged `type` arrives with no key outside the metadata set. Without + // recovery the destructure throws and the whole answer becomes N/A. + assert.equal(formatCaaRecords([ + { critical: 1, type: 'hello' }, + ]), '1 type "hello"'); + + // A tag named `critical` displaces the flag; 0 is the documented default. + assert.equal(formatCaaRecords([ + { critical: 'ca.example', type: 'CAA' }, + ]), '0 critical "ca.example"'); + }); + it('formats standard and provider-specific CAA tags from each record shape', () => { assert.equal(formatCaaRecords([ { critical: 0, type: 'CAA', issue: 'letsencrypt.org' }, From 79a5269fbc3ac291891c343da6685553a8ab83b8 Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:41:55 +0800 Subject: [PATCH 10/20] Chore(dns): align resolver diagnostics with runtime usage Keep resolver health checks on demand and surface actionable runtime availability failures through the shared logger. Remove the GitHub Actions workflow so transient resolver connectivity does not affect code CI. Co-authored-by: OpenAI Codex --- .github/workflows/dns-resolvers-health.yml | 50 ------------- api/dns-resolver.js | 23 ++++-- package.json | 1 + scripts/check-dns-resolvers.js | 12 ++++ tests/check-dns-resolvers.test.js | 14 ++++ tests/dns-resolver-logging.test.js | 83 ++++++++++++++++++++++ 6 files changed, 128 insertions(+), 55 deletions(-) delete mode 100644 .github/workflows/dns-resolvers-health.yml create mode 100644 tests/dns-resolver-logging.test.js diff --git a/.github/workflows/dns-resolvers-health.yml b/.github/workflows/dns-resolvers-health.yml deleted file mode 100644 index 0223893bc..000000000 --- a/.github/workflows/dns-resolvers-health.yml +++ /dev/null @@ -1,50 +0,0 @@ -# Run a scheduled external check so stale or restricted public resolvers are -# noticed before a contributor is sent after an unusable endpoint. -name: DNS Resolver Health - -on: - push: - branches: [dev] - schedule: - - cron: "17 3 * * *" # daily at 03:17 UTC - workflow_dispatch: - -permissions: - contents: read - -jobs: - health: - name: Check public UDP resolvers - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Install dig - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y dnsutils - - - name: Probe resolvers - id: probe - shell: bash - run: | - set +e - node scripts/check-dns-resolvers.js --json > dns-resolver-health.json - status=$? - jq -r '.markdown' dns-resolver-health.json | tee -a "$GITHUB_STEP_SUMMARY" - echo "Probe exit status: $status" - exit "$status" - - - name: Upload probe report - if: always() - uses: actions/upload-artifact@v4 - with: - name: dns-resolver-health-${{ github.run_id }} - path: dns-resolver-health.json - if-no-files-found: error - retention-days: 14 diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 7b188e87d..3b610f602 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -13,6 +13,16 @@ 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) => { + const context = { err: error, server, provider, code: error?.code }; + if (DNS_AVAILABILITY_ERRORS.has(error?.code)) { + logger.warn(context, 'DNS resolver: availability lookup failed, returning N/A'); + return; + } + logger.debug(context, 'DNS resolver: lookup failed, returning N/A'); +}; // Resolve via classic UDP DNS. Returns the raw result value: an array of // strings, a joined MX string, or 'N/A' on empty/failure. @@ -62,10 +72,7 @@ const resolveDns = async (hostname, type, name, server) => { 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,6 +85,10 @@ const resolveDoh = async (hostname, type, name, url) => { timeoutMs: DOH_TIMEOUT_MS, headers: { 'Accept': 'application/dns-json' } }); + if (!response.ok) { + logger.warn({ server: name, code: response.status }, 'DoH resolver: upstream returned a non-2xx response'); + return 'N/A'; + } const data = await response.json(); const addresses = data.Answer ? data.Answer.map(answer => answer.data) : ['N/A']; if (addresses.length === 0 || addresses === '' || addresses === null) { @@ -85,7 +96,7 @@ const resolveDoh = async (hostname, type, name, url) => { } return 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'; } }; @@ -143,8 +154,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/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/scripts/check-dns-resolvers.js b/scripts/check-dns-resolvers.js index 55dfa4130..e0d9799e9 100644 --- a/scripts/check-dns-resolvers.js +++ b/scripts/check-dns-resolvers.js @@ -18,6 +18,17 @@ 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) { @@ -237,6 +248,7 @@ export const formatMarkdown = (report) => { 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`); diff --git a/tests/check-dns-resolvers.test.js b/tests/check-dns-resolvers.test.js index 6722a9355..4455744fc 100644 --- a/tests/check-dns-resolvers.test.js +++ b/tests/check-dns-resolvers.test.js @@ -6,6 +6,7 @@ import { describe, it } from 'node:test'; import { checkDnsResolvers, checkResolver, + ensureDigAvailable, formatMarkdown, parseDigResponse, } from '../scripts/check-dns-resolvers.js'; @@ -22,6 +23,19 @@ const nxdomainResponse = (flags = 'qr rd ra') => [ 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 }), { 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/); + }); +}); From a13e89fbaed1ca67bfc8c251ad116386bd850d92 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 18:07:05 +0800 Subject: [PATCH 11/20] Chore(ui): add the shadcn-vue button-group primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Needed to join the DNS resolver's record-type Select and its input into one bordered control. Added through the shadcn-vue CLI, so the files stay verbatim upstream output and can be re-synced. The same run resynced ui/separator, which still carried a hand-written early-refactor version, to current upstream — data-slot and data-[orientation] variants instead of a JS ternary, behaviourally the same for all three existing callers. It also refreshed one transitive postcss patch in the lockfile. Co-Authored-By: Claude Opus 5 --- .../ui/button-group/ButtonGroup.vue | 22 ++++++++++++++ .../ui/button-group/ButtonGroupSeparator.vue | 28 ++++++++++++++++++ .../ui/button-group/ButtonGroupText.vue | 28 ++++++++++++++++++ frontend/components/ui/button-group/index.js | 22 ++++++++++++++ .../components/ui/separator/Separator.vue | 29 ++++++++++++------- frontend/components/ui/separator/index.js | 2 +- pnpm-lock.yaml | 2 +- 7 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 frontend/components/ui/button-group/ButtonGroup.vue create mode 100644 frontend/components/ui/button-group/ButtonGroupSeparator.vue create mode 100644 frontend/components/ui/button-group/ButtonGroupText.vue create mode 100644 frontend/components/ui/button-group/index.js 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..4fdcdc4cd --- /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/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': From aa497fb52628a81a4a3804bfcffeeb6d354fde3d Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 18:07:25 +0800 Subject: [PATCH 12/20] Fix(valid-ip): accept underscored service labels in domain names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pattern rejected any name containing an underscore, so the DNS resolver could not look up `_dmarc.example.com` or a `_domainkey` selector — the two most common TXT lookups there are — even though TXT has been a supported record type all along. Underscore is allowed as a label prefix only, per RFC 8552, and never in the TLD: `_dmarc.example.com` passes, `has_underscore.com` and `example._com` still do not. Widening the shared helper rather than adding a DNS-only variant also lets Whois and the OONI route reach the same names; both simply fail upstream on a name their provider has nothing for. Co-Authored-By: Claude Opus 5 --- common/valid-ip.js | 12 +++++++----- tests/valid-ip.test.js | 7 ++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/common/valid-ip.js b/common/valid-ip.js index 09fe24b4e..e54cd4c0c 100644 --- a/common/valid-ip.js +++ b/common/valid-ip.js @@ -37,13 +37,15 @@ function isIPv6(ip) { // Validate if a string is a syntactically plausible domain name. // Matches the hostname pattern used by DnsResolver / Whois / CensorshipCheck: -// lowercase-only labels of [a-z0-9-], at least one dot, and a TLD of 2+ -// letters. This is intentionally a surface-level check — it accepts -// "foo.example" and doesn't know about public suffixes — because every -// caller also routes through `new URL()` parsing before landing here. +// labels of [a-z0-9-], at least one dot, and a TLD of 2+ letters. Any label +// but the TLD may also carry a leading underscore, which is how RFC 8552 +// names service records — `_dmarc.example.com`, `_xmpp-server._tcp.example.com` +// — so a DNS lookup can reach them. This is intentionally a surface-level +// check: it accepts "foo.example" and doesn't know about public suffixes, +// because every caller also routes through `new URL()` parsing first. function isValidDomain(domain) { if (typeof domain !== 'string') return false; - return /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/i.test(domain); + return /^_?[a-z0-9-]+(\._?[a-z0-9-]+)*\.[a-z]{2,}$/i.test(domain); } // IPv4 blocks outside publicly routable space: the RFC 1918 private ranges 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 From c122df73be164603f66f82e4a372996aa53f4b51 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 18:07:25 +0800 Subject: [PATCH 13/20] Fix(dnsresolver): make one provider's DNS and DoH rows read identically Every provider contributes two rows to the results table, and the whole premise of the tool is that a difference between rows means something happened. Three cosmetic mismatches were reading as real disagreements: - SOA dropped the trailing root dots that the DoH path returns, though the MX branch had appended them for that exact reason. - CNAME and NS had the same gap; both transports now normalize through withRootDot, which also covers a DoH endpoint that strips them. - A SOA query below the zone apex returned N/A on every row, because the answer arrives in the authority section. dohRecords now falls back to it, filtered to the SOA type so a stray NS never leaks in. Also drops the CAA tag-collision fallback that arrived with #442: it guessed at a record shape that cannot occur, and admitted in its own comment that the flag was unrecoverable in the case it handled. The shared record-type list lands here because withRootDot needs to know which types are name-valued; the guard and the picker read it next. Co-Authored-By: Claude Opus 5 --- api/dns-resolver.js | 64 +++++++++------- common/dns-record-types.js | 13 ++++ tests/dns-resolver-formatters.test.js | 103 +++++++++++++++++++++----- 3 files changed, 135 insertions(+), 45 deletions(-) create mode 100644 common/dns-record-types.js diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 7fe63b516..c45fb2b35 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 @@ -24,9 +25,12 @@ const logDnsFailure = (error, server, provider) => { logger.debug(context, '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.nsname}.`, + `${record.hostmaster}.`, record.serial, record.refresh, record.retry, @@ -34,29 +38,37 @@ export const formatSoaRecord = (record) => [ record.minttl, ].join(' '); -// Node reports `critical` plus a constant `type: 'CAA'` as metadata alongside -// the record's one extensible property tag. +// 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']); -// RFC 8659 allows any alphanumeric tag, including one named `type` or -// `critical`. Node writes the tag onto the record under its own name, so such a -// tag overwrites the metadata field of the same name and no other key is left -// to find. Recover it from whichever field stopped holding a metadata value: -// `type` is always the constant 'CAA', and `critical` is always a number. -const caaTagEntry = (record) => { +export const formatCaaRecords = (records) => records.flatMap((record) => { const tagged = Object.entries(record).find(([key]) => !CAA_META_KEYS.has(key)); - if (tagged) return tagged; - if (record.type !== 'CAA') return ['type', record.type]; - return ['critical', record.critical]; -}; - -export const formatCaaRecords = (records) => records.map((record) => { - const [tag, value] = caaTagEntry(record); - // A tag named `critical` displaces the flag itself; it is unrecoverable. - const critical = typeof record.critical === 'number' ? record.critical : 0; - return `${critical} ${tag} ${JSON.stringify(value)}`; + 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. +export const dohRecords = (data, type) => { + if (data.Answer?.length) return data.Answer; + if (type !== 'SOA') return []; + 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 formatted record string, or 'N/A' on empty/failure. const resolveDns = async (hostname, type, name, server) => { @@ -107,6 +119,8 @@ const resolveDns = async (hostname, type, name, server) => { 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'; } @@ -130,12 +144,10 @@ const resolveDoh = async (hostname, type, name, url) => { logger.warn({ server: name, code: response.status }, 'DoH resolver: upstream returned a non-2xx response'); return 'N/A'; } - 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 '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.warn({ err: error, server: name, code: error?.code }, 'DoH resolver: lookup failed, returning N/A'); return 'N/A'; 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) }} @@ -101,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'; @@ -119,9 +132,13 @@ const errorMsg = ref(''); const combinedResults = ref([]); const countryFilter = ref('all'); -const recordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'CAA']; +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/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'; From d31785c6dd497b88079ddc113cf089e85235d664 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 18:07:41 +0800 Subject: [PATCH 16/20] Style(i18n): rewrite the DNS resolver intro copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old text was written years ago for one audience: it led with operators contaminating domains for political reasons and warned that some built-in providers were Chinese. Naming a country in product copy is not something we do any more, and it buried what the tool is. The new copy answers three questions instead — what it does, what a difference between providers usually means, and how to run it. Filtering and redirection are still named as a cause, just as a phenomenon rather than as anyone's fault. Co-Authored-By: Claude Opus 5 --- frontend/locales/en.json | 2 +- frontend/locales/fr.json | 2 +- frontend/locales/pt-BR.json | 2 +- frontend/locales/ru.json | 2 +- frontend/locales/zh.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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/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 或域名", From 2faadd452712282a0455489262cbb08f54462dbd Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 18:11:23 +0800 Subject: [PATCH 17/20] Chore(tests): add the bridge spec for the DNS record type list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every common/ helper re-exported through frontend/utils/ is supposed to ship a spec that imports both paths and asserts they agree — that is what stops a bridge from quietly regrowing a second implementation. The dns-record-types bridge shipped without one. Also pins the two invariants that would otherwise fail silently: the lookup set matching the ordered list, and every name-valued type actually being a supported one, since a type missing from the list would skip withRootDot and surface as punctuation noise rather than an error. Co-Authored-By: Claude Opus 5 --- tests/dns-record-types.test.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/dns-record-types.test.js 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()); + }); +}); From 3e3fb7bdb724e6f3ed7f3406ad394b70e1e44fb1 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 18:21:20 +0800 Subject: [PATCH 18/20] Fix(dnsresolver): keep CNAME answers out of the DoH SOA result A SOA query for an aliased name returns the CNAME chain in Answer with the zone SOA in Authority; taking Answer verbatim rendered the CNAME target as the SOA value and made the row disagree with its UDP twin. Filter SOA answers by record type on both sections. Co-Authored-By: Claude Fable 5 --- api/dns-resolver.js | 9 ++++++--- tests/dns-resolver-formatters.test.js | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/api/dns-resolver.js b/api/dns-resolver.js index c45fb2b35..23128ea26 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -62,10 +62,13 @@ 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. +// 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 (data.Answer?.length) return data.Answer; - if (type !== 'SOA') return []; + 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); }; diff --git a/tests/dns-resolver-formatters.test.js b/tests/dns-resolver-formatters.test.js index 8278cc01e..e2368b615 100644 --- a/tests/dns-resolver-formatters.test.js +++ b/tests/dns-resolver-formatters.test.js @@ -60,6 +60,19 @@ describe('DoH answer selection', () => { 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]); From f2df6e669d70189e914269efd0c7ac0fe3ea5b62 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 18:21:31 +0800 Subject: [PATCH 19/20] Fix(dnsresolver): keep the queried hostname out of availability warns Node DNS errors carry the looked-up name in err.message, and warn-level logs mirror to telemetry; the availability branch now logs only the error code, which is the whole signal for a timeout anyway. The local-only debug branch keeps the full error for diagnosis. Co-Authored-By: Claude Fable 5 --- api/dns-resolver.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/dns-resolver.js b/api/dns-resolver.js index 23128ea26..43c4359fe 100644 --- a/api/dns-resolver.js +++ b/api/dns-resolver.js @@ -17,12 +17,14 @@ const DOH_TIMEOUT_MS = 5000; const DNS_AVAILABILITY_ERRORS = new Set(['ETIMEOUT', 'ECONNREFUSED', 'EREFUSED']); const logDnsFailure = (error, server, provider) => { - const context = { err: error, server, provider, code: error?.code }; + // 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(context, 'DNS resolver: availability lookup failed, returning N/A'); + logger.warn({ server, provider, code: error?.code }, 'DNS resolver: availability lookup failed, returning N/A'); return; } - logger.debug(context, 'DNS resolver: lookup failed, returning N/A'); + 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 From 0a0a739eddc5e7b7466b7e4838cdae1ff47513c5 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 24 Aug 2026 18:21:38 +0800 Subject: [PATCH 20/20] Style: fix a misplaced guard comment and two quoting nits The provider-id comment block sat above requireValidRecordType; move it back over requireValidProviderId. Also drop needless quote escapes in the resolver-check script and match the ui/ double-quote style in ButtonGroupSeparator. Co-Authored-By: Claude Fable 5 --- common/guards.js | 6 +++--- .../components/ui/button-group/ButtonGroupSeparator.vue | 2 +- scripts/check-dns-resolvers.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/guards.js b/common/guards.js index 0da90a4c8..70f3a6738 100644 --- a/common/guards.js +++ b/common/guards.js @@ -101,9 +101,6 @@ export const requireValidReportId = (paramName = 'id') => (req, res, next) => { next(); }; -// Reject requests whose `id` isn't a known service-status provider slug. -// Used by the per-provider components / incidents endpoints, which select a -// row from the in-memory snapshot by id. // Whitelist ?type= against the record types the resolver actually handles. // Without it the DoH branch forwards any string verbatim to four third-party // endpoints, which makes this route a query proxy for types we never support. @@ -119,6 +116,9 @@ export const requireValidRecordType = (paramName = 'type') => (req, res, next) = next(); }; +// Reject requests whose `id` isn't a known service-status provider slug. +// Used by the per-provider components / incidents endpoints, which select a +// row from the in-memory snapshot by id. export const requireValidProviderId = (paramName = 'id') => (req, res, next) => { const id = req.query[paramName]; if (!id) { diff --git a/frontend/components/ui/button-group/ButtonGroupSeparator.vue b/frontend/components/ui/button-group/ButtonGroupSeparator.vue index 4fdcdc4cd..9da1bdc58 100644 --- a/frontend/components/ui/button-group/ButtonGroupSeparator.vue +++ b/frontend/components/ui/button-group/ButtonGroupSeparator.vue @@ -1,7 +1,7 @@