Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7f8051b
Feat(dns): add HiNet and GIGA (Taiwan) resolvers
fly1d Aug 24, 2026
cbc562a
Feat(dns): add CZ.NIC ODVR (Czechia) resolver
fly1d Aug 24, 2026
8bb9dc7
Feat(dns): add Yandex.DNS (Russia) resolver
fly1d Aug 24, 2026
3ae4ab7
Merge pull request #438 from fly1d/codex/quad101-resolver
jason5ng32 Aug 24, 2026
b3b11f2
Merge pull request #440 from fly1d/codex/yandex-resolver
jason5ng32 Aug 24, 2026
c9077af
Merge pull request #439 from fly1d/codex/fdn-resolver
jason5ng32 Aug 24, 2026
bb8caa6
Chore(dns): add resolver health checks
fly1d Aug 24, 2026
624656b
Feat(dnsresolver): support SOA and CAA record lookups
vsolano9 Aug 24, 2026
f3a0d92
Feat(i18n): add Brazilian Portuguese privacy policy
akidsfree Aug 24, 2026
1611c6b
Refactor(dns): centralize hostname validation
jason5ng32 Aug 24, 2026
ca726d0
Merge pull request #443 from akidsfree/codex/pt-br-privacy-policy
jason5ng32 Aug 24, 2026
3543f69
Fix(i18n): correct pt-BR privacy wording
jason5ng32 Aug 24, 2026
ffa5a07
Fix(dnsresolver): recover a CAA tag named like a metadata field
vsolano9 Aug 24, 2026
79a5269
Chore(dns): align resolver diagnostics with runtime usage
fly1d Aug 24, 2026
8e92e19
Merge pull request #441 from fly1d/codex/dns-resolver-health
jason5ng32 Aug 24, 2026
820e2ff
Merge pull request #442 from vsolano9/feat-400-dns-soa-caa
jason5ng32 Aug 24, 2026
a13e89f
Chore(ui): add the shadcn-vue button-group primitive
jason5ng32 Aug 24, 2026
aa497fb
Fix(valid-ip): accept underscored service labels in domain names
jason5ng32 Aug 24, 2026
c122df7
Fix(dnsresolver): make one provider's DNS and DoH rows read identically
jason5ng32 Aug 24, 2026
d05c6aa
Feat(api): whitelist the DNS resolver's record type
jason5ng32 Aug 24, 2026
fa7dfc9
Refactor(ui): rebuild the DNS resolver's query row and region filter
jason5ng32 Aug 24, 2026
d31785c
Style(i18n): rewrite the DNS resolver intro copy
jason5ng32 Aug 24, 2026
2faadd4
Chore(tests): add the bridge spec for the DNS record type list
jason5ng32 Aug 24, 2026
3e3fb7b
Fix(dnsresolver): keep CNAME answers out of the DoH SOA result
jason5ng32 Aug 24, 2026
f2df6e6
Fix(dnsresolver): keep the queried hostname out of availability warns
jason5ng32 Aug 24, 2026
0a0a739
Style: fix a misplaced guard comment and two quoting nits
jason5ng32 Aug 24, 2026
6eb5aad
Merge pull request #444 from jason5ng32/dev
jason5ng32 Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down
9 changes: 8 additions & 1 deletion api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions api/data/dns-resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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' },
];
104 changes: 82 additions & 22 deletions api/dns-resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,76 @@ 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
// timeout because `tries: 1`); 5s for DoH via fetchUpstream's per-call
// 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', <tag>: 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]);
Expand All @@ -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;

Expand Down Expand Up @@ -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';
}
};
Expand All @@ -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';
}
};
Expand All @@ -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
Expand Down Expand Up @@ -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;
5 changes: 3 additions & 2 deletions backend-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions common/dns-record-types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// The DNS record types /api/dnsresolver answers for — one list behind three
// consumers: the record-type <Select> in DnsResolver.vue, the requireValidRecordType
// guard, and the switch in api/dns-resolver.js that maps each type onto a
// resolver method. Array order is the order the picker renders.
export const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'CAA'];

export const DNS_RECORD_TYPE_SET = new Set(DNS_RECORD_TYPES);

// Types whose answers are domain names. Presentation form carries the root
// dot; Node's resolver drops it and a DoH endpoint may too, so both transports
// normalize through it — otherwise one provider's two rows differ on
// punctuation alone and read as a real disagreement.
export const NAME_VALUED_TYPES = new Set(['CNAME', 'NS']);
16 changes: 16 additions & 0 deletions common/guards.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { refererCheck } from './referer-check.js';
import { isValidIP, isValidDomain, isUsablePublicIP } from './valid-ip.js';
import { isValidBgpPrefix } from './bgp-prefix.js';
import { STATUS_PROVIDER_IDS } from './service-status-providers.js';
import { DNS_RECORD_TYPE_SET } from './dns-record-types.js';

// Reject requests without an allowed referer. The error message variant
// preserves the existing user-facing wording.
Expand Down Expand Up @@ -100,6 +101,21 @@ export const requireValidReportId = (paramName = 'id') => (req, res, next) => {
next();
};

// 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.
export const requireValidRecordType = (paramName = 'type') => (req, res, next) => {
const type = req.query[paramName];
if (!type) {
return res.status(400).json({ error: 'No record type provided' });
}
if (!DNS_RECORD_TYPE_SET.has(String(type).toUpperCase())) {
return res.status(400).json({ error: 'Invalid record type' });
}
req.query[paramName] = String(type).toUpperCase();
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.
Expand Down
12 changes: 7 additions & 5 deletions common/valid-ip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ Copy the named exemplar instead of re-inventing:
why-comment (ASNHistory ISO columns, report-export intro, ServiceStatus clock).
- **Fit-to-width tokens** — IP / MAC strings render in `<FitText>` (`HERO_TIERS` /
`INLINE_TIERS`; `:max-lines="2"` on heroes); never length-threshold helpers.
- **Filter tags** — an open-ended facet row (regions, IP types) is a
`ToggleGroup :spacing="2" class="w-full flex-wrap justify-start"` of detached
pills, `h-7 rounded-full px-2.5 text-xs` (IPHistory, DnsResolver). Never the
default `spacing=0` connected form: its `border-l-0` / `first:border-l` seam
only reads as one bar on a single line, and breaks the moment it wraps.
- **Fixed option sets** — a known, closed list of choices is a `Select`, not a
toggle row, once it outgrows a comfortable single line (DnsResolver's record
types, MtrTest's targets).
- **Qualifier + input + run** — wrap the qualifying `Select` and the `Input` in
a `ButtonGroup` so they read as one bordered control (the trigger's own right
border becomes the divider), and nest a second `ButtonGroup` around the run
Button for the gap (DnsResolver). Pass the trigger `w-auto shrink-0` — our
`SelectTrigger` predates `data-slot`, so ButtonGroup's own width rule misses
it — and keep the row unwrapped at every width.
- **Tables vs lists** — real per-column header semantics → `<table>`;
otherwise a bordered `<ul class="rounded-lg border bg-card divide-y">`.
- **Dialog header** — the `<DialogHeader :icon :title />` primitive.
Expand Down
Loading
Loading