Resolve overlay members by name

`--dns` serves a zone for the network's members, built from signed state,
so a member that is switched off still resolves — its claim outlived the
session. IPv4 only, as agreed: the IPv6 overlay address derives from a
key that travels in live announcements, so it cannot be answered for an
absent member, and answering for some and not others depending on who is
online is worse than not answering.

On Linux the agent tells systemd-resolved where to ask, over D-Bus.
SetLinkDNSEx carries a port, which is why the server needs neither port
53 nor CAP_NET_BIND_SERVICE; the suffix goes in as a routing domain and
the link's default route is cleared, so this never becomes the resolver
for anything else. The setting is keyed to the overlay interface, which
goes with the agent, so it cleans itself up.

That step needs permission CAP_NET_ADMIN does not give — resolved asks
polkit, and polkit decides by user, not by capability — so it is
reported as its own kind of failure with its own remedy. The server runs
regardless and status prints the exact dig line: the automatic part is
what is missing, not the feature.

The zone name is the user's to choose. One shadowing a real public
domain is reported and then used, because that is a decision; the
warning knows the IANA list, says something different about `.local`
where the clash is with mDNS, and stays quiet for names reserved for
private use.

Two bugs found by running it, both in the supervisor and neither
reachable from a unit test, so tests/dns_service.rs drives the real
binary. It bound to the allocated overlay address without checking that
address was on an interface — with --no-tun it never is — and left the
feature silently dead; it now tries the overlay first and falls back to
loopback. And it compared the address it got against the address it
wanted, which never matched when the preferred one could not be bound,
so it tore the listener down every two seconds; it now compares what it
tried.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 17:05:24 +01:00
co-authored by Claude Opus 5
parent 240e471c88
commit 75b37fdda3
14 changed files with 1553 additions and 13 deletions
+69
View File
@@ -100,6 +100,49 @@ impl ZoneName {
self.0.rsplit('.').next().unwrap_or(&self.0)
}
/// What is worrying about this zone name, if anything.
///
/// A warning and never a refusal: the name is the user's to choose, and
/// a private zone that shadows a public one is a decision, not a
/// mistake. Saying nothing would let it be an accident.
pub fn collision(&self) -> Option<String> {
let top = self.top_label();
// Reserved for exactly this use and never delegated, so nothing to
// say. See RFC 6761 and RFC 8375.
const RESERVED: &[&str] = &[
"internal",
"home",
"test",
"example",
"invalid",
"localhost",
];
if RESERVED.contains(&top) {
return None;
}
if top == "local" {
return Some(
"`.local` belongs to multicast DNS: on a host running Avahi or \
systemd-resolved's mDNS, names under it are resolved by that and \
not by this agent"
.to_string(),
);
}
if tld::exist_case_insensitive(top) {
return Some(format!(
"`.{top}` is a real top-level domain, so every public name under it \
becomes unreachable from this host while the overlay is up"
));
}
// Not delegated today is not a promise about tomorrow.
(!self.0.contains('.')).then(|| {
format!(
"`.{top}` is not a delegated top-level domain today, but it could \
become one; `.internal` is reserved for private use and never will"
)
})
}
/// Whether `name` is this zone or sits under it.
///
/// Compared label-wise, so `evilzone` does not count as being under
@@ -374,6 +417,32 @@ mod tests {
));
}
#[test]
fn a_zone_name_that_shadows_a_public_one_is_flagged_but_allowed() {
// Flagged, never refused: shadowing is the user's decision to make.
let ru = ZoneName::new("ru").unwrap();
let warning = ru.collision().expect("a real TLD is worth mentioning");
assert!(warning.contains("real top-level domain"), "{warning}");
assert!(ZoneName::new("com").unwrap().collision().is_some());
assert!(ZoneName::new("lab.com").unwrap().collision().is_some());
// Reserved for private use, so nothing to say.
for quiet in ["internal", "lab.internal", "home", "test", "invalid"] {
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
}
// `.local` is not a delegated TLD, but it is not free either.
let local = ZoneName::new("local").unwrap().collision().unwrap();
assert!(local.contains("multicast DNS"), "{local}");
// An undelegated single label is a maybe, not a yes.
let lab = ZoneName::new("lab").unwrap().collision().unwrap();
assert!(lab.contains("could"), "{lab}");
// A multi-label name under something undelegated is not worth a word.
assert_eq!(ZoneName::new("a.lab").unwrap().collision(), None);
}
#[test]
fn a_neighbouring_name_is_not_inside_the_zone() {
// `evillab` ends with `lab`, and a suffix comparison that forgot the