Skip to content

Commit 699170f

Browse files
Frandoclaude
andauthored
feat!: replace DNS overlay with in-process DNS server (#13)
The old DNS system wrote per-device `/etc/hosts` files and bind-mounted them into every namespace thread. Lab-wide `dns_entry()` calls rewrote all files on every mutation, and hickory-resolver could miss late entries because it reads `/etc/hosts` at construction time. This replaces it with an in-process DNS server on the IX bridge. Records live in a `std::sync::RwLock<HashMap>` and are served over UDP via hickory-proto on both v4 and v6. All mutations are synchronous and visible to queries before `set_host()` returns. Per-device `/etc/hosts` overlays are kept for device-local isolation via `Device::set_host()`. `Device::resolve()` is now async, using `tokio::net::lookup_host` on the device's async worker so it does not block the runtime under link impairment. DNS names are normalized to FQDN internally, so both `"relay.test"` and `"relay.test."` work. ### Breaking changes - `Lab::dns_entry()` removed — use `lab.dns_server()?.set_host()` instead. - `Lab::set_nameserver()` removed — calling `dns_server()` auto-sets `resolv.conf`. - `Device::dns_entry()` renamed to `Device::set_host()`. - `Device::resolve()` is now async. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6ad9f77 commit 699170f

8 files changed

Lines changed: 732 additions & 169 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

patchbay/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ serde = { version = "1", features = ["derive"] }
2525
serde_json = "1"
2626
strum = { version = "0.28", features = ["derive"] }
2727
tokio = { version = "1", features = ["rt", "macros", "sync", "time", "fs", "net", "io-util", "process"] }
28+
hickory-proto = { version = "0.25", default-features = false }
2829
tokio-util = "0.7"
2930
toml = "1.0"
3031
tracing = "0.1"

patchbay/src/core.rs

Lines changed: 36 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -372,118 +372,76 @@ pub(crate) struct IfaceBuild {
372372

373373
/// Per-device DNS host entries for `/etc/hosts` overlay.
374374
///
375-
/// Each device gets a persistent hosts file at `<hosts_dir>/<node_id>.hosts`.
376-
/// A shared `resolv.conf` lives at `<hosts_dir>/resolv.conf`. Files are created
377-
/// at init with default content and bind-mounted into worker threads at startup.
378-
/// Subsequent `dns_entry()` / `set_nameserver()` calls just rewrite the file —
379-
/// glibc picks up changes via mtime check on next `getaddrinfo()`.
380-
pub(crate) struct DnsEntries {
381-
/// Lab-wide entries applied to every device.
382-
pub global: Vec<(String, IpAddr)>,
383-
/// Per-device entries, keyed by device `NodeId`.
384-
pub per_device: HashMap<NodeId, Vec<(String, IpAddr)>>,
385-
/// Optional nameserver IP for `/etc/resolv.conf` overlay.
386-
pub nameserver: Option<IpAddr>,
375+
/// Per-device `/etc/hosts` overlay and shared `resolv.conf`.
376+
///
377+
/// Each device gets a hosts file at `<hosts_dir>/<node_id>.hosts` and a shared
378+
/// `resolv.conf` at `<hosts_dir>/resolv.conf`, bind-mounted into worker threads.
379+
/// Lab-wide DNS records live in [`DnsServer`](crate::dns_server::DnsServer).
380+
pub(crate) struct DnsOverlayDir {
381+
/// Nameservers for `/etc/resolv.conf` overlay.
382+
pub nameservers: Vec<IpAddr>,
387383
/// Directory for generated hosts/resolv files.
388384
pub hosts_dir: PathBuf,
389385
}
390386

391-
impl DnsEntries {
387+
impl DnsOverlayDir {
392388
fn new(prefix: &str) -> Result<Self> {
393389
let hosts_dir = std::env::temp_dir().join(format!("patchbay-{prefix}-hosts"));
394390
std::fs::create_dir_all(&hosts_dir).context("create hosts dir")?;
395-
// Create initial resolv.conf with localhost as fallback nameserver.
396-
// glibc and hickory-resolver need at least one nameserver entry.
397-
let resolv_path = hosts_dir.join("resolv.conf");
398391
std::fs::write(
399-
&resolv_path,
392+
hosts_dir.join("resolv.conf"),
400393
"# generated by patchbay\nnameserver 127.0.0.53\n",
401394
)
402395
.context("write initial resolv.conf")?;
403396
Ok(Self {
404-
global: Vec::new(),
405-
per_device: HashMap::new(),
406-
nameserver: None,
397+
nameservers: Vec::new(),
407398
hosts_dir,
408399
})
409400
}
410401

411-
/// Returns the path to the hosts file for a device. Always valid after
412-
/// `ensure_hosts_file()` has been called for this device.
413402
pub(crate) fn hosts_path_for(&self, device_id: NodeId) -> PathBuf {
414403
self.hosts_dir.join(format!("{}.hosts", device_id.0))
415404
}
416405

417-
/// Returns the path to the shared resolv.conf overlay.
418406
pub(crate) fn resolv_path(&self) -> PathBuf {
419407
self.hosts_dir.join("resolv.conf")
420408
}
421409

422-
/// Creates the hosts file for a device with default content if it doesn't exist.
410+
/// Creates the default hosts file for a device if it doesn't exist.
423411
pub(crate) fn ensure_hosts_file(&self, device_id: NodeId) -> Result<()> {
424412
let path = self.hosts_path_for(device_id);
425413
if !path.exists() {
426-
self.write_hosts_file(device_id)?;
414+
std::fs::write(
415+
&path,
416+
"# generated by patchbay\n127.0.0.1\tlocalhost\n::1\tlocalhost\n",
417+
)
418+
.with_context(|| format!("write {}", path.display()))?;
427419
}
428420
Ok(())
429421
}
430422

431-
/// Writes (or rewrites) the hosts file for a single device.
432-
pub(crate) fn write_hosts_file(&self, device_id: NodeId) -> Result<()> {
423+
/// Appends a host entry to a device's hosts file.
424+
pub(crate) fn append_host(&self, device_id: NodeId, name: &str, ip: IpAddr) -> Result<()> {
425+
use std::io::Write;
433426
let path = self.hosts_path_for(device_id);
434-
let mut buf =
435-
String::from("# generated by patchbay\n127.0.0.1\tlocalhost\n::1\tlocalhost\n");
436-
for (name, ip) in &self.global {
437-
buf.push_str(&format!("{ip}\t{name}\n"));
438-
}
439-
if let Some(entries) = self.per_device.get(&device_id) {
440-
for (name, ip) in entries {
441-
buf.push_str(&format!("{ip}\t{name}\n"));
442-
}
443-
}
444-
std::fs::write(&path, buf.as_bytes())
445-
.with_context(|| format!("write {}", path.display()))?;
446-
Ok(())
447-
}
448-
449-
/// Rewrites hosts files for all given devices.
450-
pub(crate) fn write_all_hosts_files(&self, device_ids: &[NodeId]) -> Result<()> {
451-
for &id in device_ids {
452-
self.write_hosts_file(id)?;
453-
}
427+
let mut f = std::fs::OpenOptions::new()
428+
.append(true)
429+
.open(&path)
430+
.with_context(|| format!("open {}", path.display()))?;
431+
writeln!(f, "{ip}\t{name}").with_context(|| format!("append to {}", path.display()))?;
454432
Ok(())
455433
}
456434

457-
/// Writes the resolv.conf file with the current nameserver setting.
458435
pub(crate) fn write_resolv_conf(&self) -> Result<()> {
459436
let path = self.resolv_path();
460-
let content = match self.nameserver {
461-
Some(ip) => format!("# generated by patchbay\nnameserver {ip}\n"),
462-
None => "# generated by patchbay\n".to_string(),
463-
};
437+
let mut content = String::from("# generated by patchbay\n");
438+
for ip in &self.nameservers {
439+
content.push_str(&format!("nameserver {ip}\n"));
440+
}
464441
std::fs::write(&path, content.as_bytes())
465442
.with_context(|| format!("write {}", path.display()))?;
466443
Ok(())
467444
}
468-
469-
/// Resolves a name using global + per-device entries. Returns the first match.
470-
pub(crate) fn resolve(&self, device_id: Option<NodeId>, name: &str) -> Option<IpAddr> {
471-
if let Some(id) = device_id {
472-
if let Some(entries) = self.per_device.get(&id) {
473-
for (n, ip) in entries {
474-
if n == name {
475-
return Some(*ip);
476-
}
477-
}
478-
}
479-
}
480-
for (n, ip) in &self.global {
481-
if n == name {
482-
return Some(*ip);
483-
}
484-
}
485-
None
486-
}
487445
}
488446

489447
/// Per-region metadata stored in `NetworkCore`.
@@ -567,6 +525,8 @@ pub(crate) struct LabInner {
567525
pub ipv6_dad_mode: Ipv6DadMode,
568526
/// IPv6 provisioning behavior.
569527
pub ipv6_provisioning_mode: Ipv6ProvisioningMode,
528+
/// In-process DNS server on the IX bridge (lazy, started on first access).
529+
pub dns_server: std::sync::Mutex<Option<crate::dns_server::DnsServer>>,
570530
/// Writer task handle (kept alive until lab is dropped).
571531
pub writer_handle: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
572532
/// Test outcome flag shared with the writer and [`TestGuard`].
@@ -580,6 +540,9 @@ pub(crate) struct LabInner {
580540
impl Drop for LabInner {
581541
fn drop(&mut self) {
582542
self.cancel.cancel();
543+
if let Some(dns) = self.dns_server.get_mut().unwrap().take() {
544+
dns.shutdown();
545+
}
583546

584547
// Determine final status from the test guard.
585548
let status = match self.test_status.load(Ordering::Acquire) {
@@ -652,7 +615,7 @@ impl LabInner {
652615
pub(crate) struct NetworkCore {
653616
pub(crate) cfg: CoreConfig,
654617
/// DNS host entries for `/etc/hosts` overlay in spawned commands.
655-
pub(crate) dns: DnsEntries,
618+
pub(crate) dns: DnsOverlayDir,
656619
next_id: u64,
657620
next_private_subnet: u16,
658621
next_public_subnet: u16,
@@ -688,7 +651,7 @@ impl Drop for NetworkCore {
688651
impl NetworkCore {
689652
/// Constructs a new topology core and pre-creates the IX switch.
690653
pub(crate) fn new(cfg: CoreConfig) -> Result<Self> {
691-
let dns = DnsEntries::new(&cfg.prefix).context("create DNS entries dir")?;
654+
let dns = DnsOverlayDir::new(&cfg.prefix).context("create DNS entries dir")?;
692655
let mut core = Self {
693656
cfg,
694657
dns,
@@ -1682,13 +1645,6 @@ impl NetworkCore {
16821645
Ok((ns, old_ip, new_ip, prefix_len))
16831646
}
16841647

1685-
/// Adds a global DNS entry and writes all hosts files.
1686-
pub(crate) fn add_dns_entry(&mut self, name: &str, ip: IpAddr) -> Result<()> {
1687-
self.dns.global.push((name.to_string(), ip));
1688-
let ids: Vec<_> = self.all_device_ids();
1689-
self.dns.write_all_hosts_files(&ids)
1690-
}
1691-
16921648
// ── Link target resolution ───────────────────────────────────────
16931649

16941650
/// Resolves the `(namespace, ifname)` for impairment between two connected nodes.

0 commit comments

Comments
 (0)