Skip to content

Commit d3e8c31

Browse files
calliclesclaude
andcommitted
Discover anycast sites via NSID, falling back to id.server probes
Site discovery relied on operator-specific identification queries, so only the six resolvers we had hand-wired reported where they actually answered from. NSID (RFC 5001) is the general form of the same question: an EDNS option sent empty on any query, which the answering node echoes back filled with its own identity string. It needs no per-operator support and works on servers that never implemented `id.server`. `dns::nsid` sends a `. NS` carrier query with an empty NSID option — every recursive resolver has the root NS set cached, and it keeps the probe from leaking the domain being watched. Payloads are opaque per RFC 5001, so printable ASCII is taken as-is and anything else is hex-encoded rather than mangled through lossy UTF-8. `sites::parse_nsid` scans the string's labels left to right for a known airport code, handling the node-number suffix (`yul01`, `CS-YYZ3`) and Quad9's `q<iata><n>` POP form. Requiring a *known* airport is deliberate: many NSID strings are pure infrastructure (`pdns-recursor-58b7c5d77d-bjzkj`), and inventing a location from those would be worse than showing the operator's configured region. `discover` now takes `Option<SiteProbe>` and tries NSID first, so every resolver is probed — including ones the user added in the config file, which carry no probe at all. When NSID names no place the old operator query runs exactly as before, so nothing that already resolved to a site changed. Verified against the live resolver list with `--once`: Lumen `US → →JFK`, CIRA `CA → →YYZ`, DNS4EU `EU/Any → →AMS`, DNS.SB `DE/Any → →KIX`, with Google/Cloudflare/Quad9/OpenDNS/CleanBrowsing/UltraDNS unchanged; the same codes render in the TUI driven through a PTY, and a config-only resolver list now reports `→YUL` and `→JFK` where it previously showed `HQ`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5458b5e commit d3e8c31

5 files changed

Lines changed: 213 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- Anycast site discovery now asks every resolver for its NSID (RFC 5001)
12+
first — a standard EDNS option servers answer with their own node name —
13+
and only falls back to the old operator-specific `id.server` probes when
14+
that names no place. More resolvers report where they actually answered
15+
from: Lumen shows `→JFK`, CIRA Canadian Shield `→YYZ`, DNS4EU `→AMS` and
16+
DNS.SB `→KIX` where they used to show only the operator's home region.
17+
Resolvers you add yourself in the config file can now report a site too,
18+
since NSID needs no per-operator support, and Google's site takes one
19+
query instead of two. Nothing that already resolved to a site changed.
20+
([#36](https://github.com/514-labs/dnsglobe/issues/36),
21+
[#39](https://github.com/514-labs/dnsglobe/pull/39))
22+
923
### Fixed
1024

1125
- Names with an underscore in the middle of a label are queried instead of

README.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,17 @@ globe, which needs fewer columns; resizing across the threshold morphs one
3535
into the other. Ctrl+O toggles map/globe by hand, and `--view auto|map|globe`
3636
(or `view = "..."` in the config file) forces a style outright.
3737

38-
Anycast networks are asked which of their sites is answering you: Quad9
39-
(`TXT id.server.on.quad9.net`), Cloudflare (`CH TXT id.server`), Google
40-
(egress subnet via `TXT o-o.myaddr.l.google.com` matched against
41-
`TXT locations.publicdns.goog`), OpenDNS (`TXT debug.opendns.com`),
42-
CleanBrowsing, and Neustar UltraDNS. The discovered site shows in the Loc
43-
column as `→YUL`-style codes, and the resolver's map dot moves to the POP
44-
actually serving your queries.
38+
Every resolver is asked which of its sites is answering you, via NSID
39+
(RFC 5001) — an EDNS option the answering node fills with its own name
40+
(`gpdns-yul`, `yul01`, `res721.qyul1`, `jfk-dns1-02.inet.centurylink.net`).
41+
It needs no per-operator support, so resolvers you add yourself can report a
42+
site too. Where NSID names no place, the operator-specific identification
43+
queries take over: Quad9 (`TXT id.server.on.quad9.net`), Cloudflare
44+
(`CH TXT id.server`), Google (egress subnet via
45+
`TXT o-o.myaddr.l.google.com` matched against `TXT locations.publicdns.goog`),
46+
OpenDNS (`TXT debug.opendns.com`), CleanBrowsing, and Neustar UltraDNS. The
47+
discovered site shows in the Loc column as `→YUL`-style codes, and the
48+
resolver's map dot moves to the POP actually serving your queries.
4549

4650
## Usage
4751

@@ -155,9 +159,9 @@ startup with the offending entry named.
155159
## Notes
156160

157161
- Several resolvers are anycast networks, so the responding node is the one
158-
nearest to you. Networks with an identification query report the actual
159-
answering site (`→YUL`); for the rest the location column is the
160-
operator's home region.
162+
nearest to you. Networks that identify their node — via NSID or an
163+
identification query — report the actual answering site (`→YUL`); for the
164+
rest the location column is the operator's home region.
161165
- The built-in resolver list lives in `src/resolvers.rs`; use the config file
162166
above to extend or replace it without rebuilding. Every built-in entry was
163167
verified to answer external queries; many well-known ISP resolvers (and,

src/dns.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use hickory_resolver::config::{NameServerConfig, ResolveHosts, ResolverConfig};
77
use hickory_resolver::net::runtime::TokioRuntimeProvider;
88
use hickory_resolver::net::{DnsError, NetError};
99
use hickory_resolver::proto::op::{Edns, Message, Query, ResponseCode};
10-
use hickory_resolver::proto::rr::rdata::opt::{EdnsCode, EdnsOption};
10+
use hickory_resolver::proto::rr::rdata::opt::{EdnsCode, EdnsOption, NSIDPayload};
1111
use hickory_resolver::proto::rr::{DNSClass, Name, RData, RecordType};
1212
use tokio::io::{AsyncReadExt, AsyncWriteExt};
1313

@@ -358,6 +358,55 @@ async fn exchange_tcp(server: IpAddr, request: &[u8], id: u16) -> Option<Message
358358
(response.metadata.id == id).then_some(response)
359359
}
360360

361+
/// NSID request (RFC 5001): an EDNS option we send *empty* — that empty form
362+
/// is the request — which the server answers by echoing the option filled
363+
/// with its own identity string. It rides an ordinary query, so any server
364+
/// that implements it identifies itself without needing `id.server` support.
365+
///
366+
/// The carrier query is `. NS`: every recursive resolver has the root NS set
367+
/// cached, so it is the cheapest thing to ask, and it keeps the probe from
368+
/// leaking the domain the user is watching.
369+
fn nsid_message() -> Message {
370+
let mut message = Message::query();
371+
message.metadata.recursion_desired = true;
372+
message.add_query(Query::query(Name::root(), RecordType::NS));
373+
let mut edns = Edns::new();
374+
edns.set_max_payload(EDNS_PAYLOAD);
375+
edns.options_mut().insert(EdnsOption::NSID(
376+
NSIDPayload::new(Vec::new()).expect("an empty NSID payload always fits"),
377+
));
378+
message.edns = Some(edns);
379+
message
380+
}
381+
382+
/// RFC 5001 leaves the payload opaque, but operators put a printable ASCII
383+
/// host/site name in it. Anything else is hex-encoded (what `dig +nsid`
384+
/// shows) rather than dropped or mangled through lossy UTF-8, which could
385+
/// invent letters that never crossed the wire. An empty payload — some
386+
/// servers echo the option back unfilled — identifies nothing, so it is None.
387+
fn decode_nsid(payload: &[u8]) -> Option<String> {
388+
let text = if payload.iter().all(|b| (0x20..=0x7e).contains(b)) {
389+
String::from_utf8(payload.to_vec()).ok()?.trim().to_string()
390+
} else {
391+
payload.iter().map(|b| format!("{b:02x}")).collect()
392+
};
393+
(!text.is_empty()).then_some(text)
394+
}
395+
396+
/// Ask a server to identify itself via NSID. None means it does not support
397+
/// the option (or did not answer) — the caller falls back to an `id.server`
398+
/// probe, and failing that the resolver keeps its configured location.
399+
pub async fn nsid(server: IpAddr) -> Option<String> {
400+
let message = nsid_message();
401+
let response = exchange(server, &message).await.ok()?;
402+
match response.edns.as_ref()?.option(EdnsCode::NSID)? {
403+
EdnsOption::NSID(payload) => decode_nsid(payload.as_ref()),
404+
// A server that echoes code 3 with something hickory could not parse
405+
// as an NSID payload is not identifying itself in any usable way.
406+
_ => None,
407+
}
408+
}
409+
361410
/// IN TXT query returning each TXT character-string separately (a record's
362411
/// strings are answers like `"prefix code"` entries — joining them would
363412
/// destroy the structure). Used by the anycast site probes; None on any
@@ -563,6 +612,32 @@ mod tests {
563612
);
564613
}
565614

615+
#[test]
616+
fn nsid_message_requests_an_empty_option() {
617+
let message = nsid_message();
618+
// RFC 5001 §2.1: the requester sends the option with zero-length data.
619+
let edns = message.edns.as_ref().unwrap();
620+
match edns.option(EdnsCode::NSID).unwrap() {
621+
EdnsOption::NSID(payload) => assert!(payload.as_ref().is_empty()),
622+
other => panic!("expected an NSID option, got {other:?}"),
623+
}
624+
let query = &message.queries[0];
625+
assert!(query.name().is_root());
626+
assert_eq!(query.query_type(), RecordType::NS);
627+
}
628+
629+
#[test]
630+
fn nsid_payloads_decode_as_ascii_or_hex() {
631+
assert_eq!(decode_nsid(b"gpdns-yul").as_deref(), Some("gpdns-yul"));
632+
// Trailing whitespace/NULs some servers pad with carry no meaning.
633+
assert_eq!(decode_nsid(b" yul01 ").as_deref(), Some("yul01"));
634+
// Non-ASCII stays verifiable as hex instead of becoming U+FFFD.
635+
assert_eq!(decode_nsid(&[0xc0, 0xff, 0xee]).as_deref(), Some("c0ffee"));
636+
// An echoed-but-unfilled option identifies nothing.
637+
assert_eq!(decode_nsid(b""), None);
638+
assert_eq!(decode_nsid(b" "), None);
639+
}
640+
566641
/// A response as classify sees it: flip the id-generated query into a
567642
/// response with the given code, answers, and (optionally) an ECS echo.
568643
fn response(

src/main.rs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -430,13 +430,13 @@ fn poll_query(app: &mut App, tx: &mpsc::UnboundedSender<QueryOutcome>) {
430430
spawn_round(app, tx, round);
431431
}
432432

433-
/// Ask each anycast resolver which of its sites is answering us (issue #6).
434-
/// One shot per run: the site follows our network path, not the query.
433+
/// Ask each resolver which of its sites is answering us (issues #6 and #36).
434+
/// One shot per run: the site follows our network path, not the query. Every
435+
/// resolver is asked — NSID needs no per-operator support, so even one the
436+
/// user added themselves can identify its node.
435437
fn spawn_site_probes(app: &App, site_tx: &mpsc::UnboundedSender<(IpAddr, sites::Site)>) {
436438
for resolver in &app.resolvers {
437-
let Some(probe) = resolver.probe else {
438-
continue;
439-
};
439+
let probe = resolver.probe;
440440
let site_tx = site_tx.clone();
441441
let server = resolver.ip;
442442
tokio::spawn(async move {
@@ -480,10 +480,8 @@ async fn run_once(domain: String, rtype: RecordType, ecs_list: Vec<ClientSubnet>
480480
// Site probes run concurrently with the first query round.
481481
let mut probes = tokio::task::JoinSet::new();
482482
for (index, resolver) in app.resolvers.iter().enumerate() {
483-
if let Some(probe) = resolver.probe {
484-
let server = resolver.ip;
485-
probes.spawn(async move { (index, sites::discover(probe, server).await) });
486-
}
483+
let (probe, server) = (resolver.probe, resolver.ip);
484+
probes.spawn(async move { (index, sites::discover(probe, server).await) });
487485
}
488486

489487
let selections: Vec<Option<usize>> = if app.ecs_list.is_empty() {

src/sites.rs

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
//! Anycast site discovery: ask a resolver *which* of its sites answered.
22
//!
3-
//! Large anycast networks expose an identification query — Quad9 answers
4-
//! `TXT id.server.on.quad9.net`, Cloudflare answers `CH TXT id.server`,
5-
//! Google reports its egress subnet, OpenDNS has `TXT debug.opendns.com`.
6-
//! The answer names the POP (usually by IATA airport code), telling you
7-
//! where your queries actually land instead of the operator's home region.
3+
//! The general answer is NSID (RFC 5001): an EDNS option every query can
4+
//! carry, which the answering node fills with its own identity string —
5+
//! `gpdns-yul` (Google), `yul01` (Cloudflare), `res721.qyul1` (Quad9),
6+
//! `jfk-dns1-02.inet.centurylink.net` (Lumen). One option on one query, no
7+
//! per-operator special casing, and it works on servers that never
8+
//! implemented an identification *query*. See
9+
//! <https://github.com/514-labs/dnsglobe/issues/36>.
10+
//!
11+
//! Where NSID names no recognizable place, the operator-specific probes stay
12+
//! as the fallback — Quad9 answers `TXT id.server.on.quad9.net`, Cloudflare
13+
//! answers `CH TXT id.server`, Google reports its egress subnet, OpenDNS has
14+
//! `TXT debug.opendns.com`. Either way the answer names the POP (usually by
15+
//! IATA airport code), telling you where your queries actually land instead
16+
//! of the operator's home region.
817
//! See <https://github.com/514-labs/dnsglobe/issues/6>.
918
1019
use std::net::IpAddr;
@@ -48,10 +57,25 @@ impl Site {
4857
}
4958
}
5059

51-
/// Run one resolver's identification query. None means the probe failed or
52-
/// the answer was unparseable — the resolver keeps its configured location.
53-
pub async fn discover(probe: SiteProbe, server: IpAddr) -> Option<Site> {
54-
match probe {
60+
/// Identify the answering node. NSID goes first — it costs one query, needs
61+
/// no operator-specific support, and is the only thing we can ask a resolver
62+
/// the user added themselves (those carry no `probe`). When it names no
63+
/// place we fall back to the operator's identification query, and when that
64+
/// fails too the resolver keeps its configured location.
65+
pub async fn discover(probe: Option<SiteProbe>, server: IpAddr) -> Option<Site> {
66+
if let Some(id) = dns::nsid(server).await {
67+
if let Some(site) = parse_nsid(&id) {
68+
return Some(site);
69+
}
70+
// Operators with a free-form site name publish the same string over
71+
// NSID as over `id.server`, so asking again would only repeat it.
72+
if probe == Some(SiteProbe::ChIdServer)
73+
&& let Some(site) = parse_freeform(std::slice::from_ref(&id))
74+
{
75+
return Some(site);
76+
}
77+
}
78+
match probe? {
5579
SiteProbe::Quad9 => {
5680
let strings = dns::txt_strings(server, "id.server.on.quad9.net").await?;
5781
parse_quad9(&strings)
@@ -76,6 +100,38 @@ pub async fn discover(probe: SiteProbe, server: IpAddr) -> Option<Site> {
76100
}
77101
}
78102

103+
/// Pull the POP out of an NSID string. Operators encode it as one label of
104+
/// an otherwise free-form host name — `gpdns-yul`, `yul01`, `res721.qyul1`,
105+
/// `r2005.yyz`, `CS-YYZ3`, `dns4eu-ams-01`, `jp-kix-5` — so scan the labels
106+
/// left to right and take the first that is a known airport.
107+
///
108+
/// Requiring a *known* airport is the point: plenty of NSID strings are pure
109+
/// infrastructure (`pdns-recursor-58b7c5d77d-bjzkj`, `tserv21`, `230@dns9`),
110+
/// and inventing a location from those would be worse than showing the
111+
/// operator's configured region.
112+
fn parse_nsid(id: &str) -> Option<Site> {
113+
id.split(|c: char| !c.is_ascii_alphanumeric())
114+
.find_map(iata_in_label)
115+
.map(|code| Site::from_code(&code))
116+
}
117+
118+
/// A known airport code inside one NSID label, if any. Node numbering is
119+
/// suffixed (`yul01`, `yyz3`), and Quad9 prefixes its POPs with `q`
120+
/// (`qyul1`), so both forms reduce to a bare three-letter code.
121+
fn iata_in_label(label: &str) -> Option<String> {
122+
let stem = label.trim_end_matches(|c: char| c.is_ascii_digit());
123+
if !stem.chars().all(|c| c.is_ascii_alphabetic()) {
124+
return None;
125+
}
126+
let code = match stem.len() {
127+
3 => stem,
128+
4 if stem.starts_with(['q', 'Q']) => &stem[1..],
129+
_ => return None,
130+
}
131+
.to_ascii_uppercase();
132+
airport_coords(&code).is_some().then_some(code)
133+
}
134+
79135
/// `res120.qyul1.on.quad9.net` → the label starting with `q` names the POP:
80136
/// strip the `q` and the trailing node digits to get the IATA code.
81137
fn parse_quad9(strings: &[String]) -> Option<Site> {
@@ -469,6 +525,43 @@ mod tests {
469525
}
470526
}
471527

528+
/// Every string here was captured off the wire with `dig +nsid`.
529+
#[test]
530+
fn real_nsid_strings_yield_their_pop() {
531+
for (id, code) in [
532+
("gpdns-yul", "YUL"), // Google
533+
("yul01", "YUL"), // Cloudflare
534+
("res721.qyul1", "YUL"), // Quad9
535+
("r2005.yyz", "YYZ"), // OpenDNS
536+
("jfk-dns1-02.inet.centurylink.net", "JFK"), // Lumen
537+
("CS-YYZ3", "YYZ"), // CIRA Canadian Shield
538+
("dns4eu-ams-01", "AMS"), // DNS4EU
539+
("jp-kix-5", "KIX"), // DNS.SB
540+
] {
541+
let site = parse_nsid(id).unwrap_or_else(|| panic!("{id} yielded no site"));
542+
assert_eq!(site.code, code, "from {id}");
543+
assert!(site.coords.is_some(), "{code} should be on the map");
544+
}
545+
}
546+
547+
/// NSID strings that name no place must stay silent rather than promote
548+
/// a hostname fragment to a location.
549+
#[test]
550+
fn nsid_without_a_known_airport_is_none() {
551+
for id in [
552+
"pdns-recursor-58b7c5d77d-bjzkj", // FortiGuard
553+
"tserv21", // Hurricane Electric
554+
"230@dns9", // CZ.NIC ODVR
555+
"ny2-hw-edge-gc7.fe.gc.onl", // Gcore
556+
"us-dns-rh1s",
557+
"pad", // Telstra: not an airport we map
558+
"",
559+
"c0ffee", // a hex-encoded binary payload
560+
] {
561+
assert!(parse_nsid(id).is_none(), "{id} should name no site");
562+
}
563+
}
564+
472565
#[test]
473566
fn quad9_pop_name_yields_iata() {
474567
let site = parse_quad9(&strings(&["res120.qyul1.on.quad9.net"])).unwrap();

0 commit comments

Comments
 (0)