Skip to content

Commit 2581a62

Browse files
committed
fix: implement two-pronged DNS strategy for robust resolution
## DNS Strategy Overview Run TWO DNS servers to handle all /etc/resolv.conf configurations: 1. **Namespace DNS server** (separate process in namespace): - Binds to loopback addresses (127.0.0.53, 127.0.0.54) - Handles queries when /etc/resolv.conf points to loopback (systemd-resolved) - These queries never leave namespace, so DNAT doesn't apply 2. **Host DNS server** (runs in main jail process): - Binds to host_ip:53 on host side of veth pair - Handles queries to external nameservers (e.g., 8.8.8.8) - DNAT redirects outbound DNS queries to this server - Works because DNAT to localhost fails for locally-generated packets ## Changes - dns.rs: Update namespace server to bind only to loopback addresses - mod.rs: Add host_dns_server field, start both servers with detailed comments - nftables.rs: Allow both host_ip and loopback DNS, redirect external queries - main.rs: Pass host_ip to namespace DNS server process This approach works regardless of /etc/resolv.conf configuration.
1 parent 78929a3 commit 2581a62

4 files changed

Lines changed: 96 additions & 31 deletions

File tree

src/jail/linux/dns.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,20 @@ fn build_dummy_response(query: Packet<'_>) -> Result<Vec<u8>> {
149149

150150
/// Run DNS server synchronously (blocks forever). Used when spawned inside namespace.
151151
///
152-
/// We bind to multiple addresses to handle different /etc/resolv.conf configurations:
153-
/// - 0.0.0.0:53 - Catches most queries via DNAT rules
154-
/// - 127.0.0.53:53 - Used by systemd-resolved on Ubuntu/Debian
155-
/// - 127.0.0.54:53 - Alternative systemd-resolved address
152+
/// This handles DNS queries when /etc/resolv.conf points to loopback addresses
153+
/// (e.g., 127.0.0.53 used by systemd-resolved). These queries don't leave the namespace
154+
/// and aren't subject to DNAT rules, so we bind directly to those loopback addresses.
155+
///
156+
/// For queries to external nameservers (e.g., 8.8.8.8), those are handled by a separate
157+
/// DNS server running in the host process (see start_dns_server in mod.rs), which binds
158+
/// to the host_ip. DNAT redirects outbound DNS queries to that host_ip:53 server.
156159
///
157160
/// We intentionally DO NOT modify /etc/resolv.conf to avoid side effects and maintain
158161
/// robustness across different system configurations.
159-
pub fn run_dns_server_blocking() -> Result<()> {
160-
// Bind to multiple addresses to handle different nameserver configurations
161-
let addresses = vec!["0.0.0.0:53", "127.0.0.53:53", "127.0.0.54:53"];
162+
pub fn run_dns_server_blocking(_host_ip: &str) -> Result<()> {
163+
// Bind to loopback addresses for direct queries (systemd-resolved, etc.)
164+
// These handle the case where /etc/resolv.conf points to 127.0.0.x
165+
let addresses = vec!["127.0.0.53:53".to_string(), "127.0.0.54:53".to_string()];
162166

163167
let mut servers = Vec::new();
164168
for addr in &addresses {

src/jail/linux/mod.rs

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ pub struct LinuxJail {
6565
veth_pair: Option<ManagedResource<VethPair>>,
6666
nftables: Option<ManagedResource<NFTable>>,
6767
dns_server_child: Option<std::process::Child>,
68+
// Host-side DNS server for DNAT redirection
69+
host_dns_server: Option<dns::DummyDnsServer>,
6870
// Per-jail computed networking (unique /30 inside 10.99/16)
6971
host_ip: [u8; 4],
7072
host_cidr: String,
@@ -82,6 +84,7 @@ impl LinuxJail {
8284
veth_pair: None,
8385
nftables: None,
8486
dns_server_child: None,
87+
host_dns_server: None,
8588
host_ip,
8689
host_cidr,
8790
guest_cidr,
@@ -362,17 +365,38 @@ impl LinuxJail {
362365
Ok(())
363366
}
364367

365-
/// Start the dummy DNS server in the namespace
368+
/// Start DNS servers using a two-pronged approach to handle different /etc/resolv.conf configs
369+
///
370+
/// ## DNS Strategy Overview
371+
///
372+
/// We run TWO DNS servers to handle all cases robustly:
373+
///
374+
/// 1. **Namespace DNS server** (separate process in namespace):
375+
/// - Binds to loopback addresses (127.0.0.53, 127.0.0.54)
376+
/// - Handles queries when /etc/resolv.conf points to loopback (e.g., systemd-resolved)
377+
/// - These queries never leave the namespace, so DNAT doesn't apply
378+
///
379+
/// 2. **Host DNS server** (runs in this process on host side):
380+
/// - Binds to host_ip:53 on the host side of the veth pair
381+
/// - Handles queries to external nameservers (e.g., 8.8.8.8 in /etc/resolv.conf)
382+
/// - DNAT rules redirect outbound DNS queries to this server
383+
/// - This works because DNAT to localhost doesn't work for locally-generated packets
384+
/// in the OUTPUT chain, but DNAT to a routable IP (the host_ip) does work
385+
///
386+
/// This two-pronged approach works regardless of /etc/resolv.conf configuration.
366387
fn start_dns_server(&mut self) -> Result<()> {
367388
let namespace_name = self.namespace_name();
389+
let host_ip_str = format!(
390+
"{}.{}.{}.{}",
391+
self.host_ip[0], self.host_ip[1], self.host_ip[2], self.host_ip[3]
392+
);
368393

394+
// 1. Start namespace DNS server for loopback queries
369395
info!(
370-
"Starting dummy DNS server inside namespace {}",
396+
"Starting namespace DNS server inside {} for loopback addresses",
371397
namespace_name
372398
);
373399

374-
// Spawn httpjail inside the namespace to run the DNS server
375-
// This uses the --__internal-dns-server flag which calls run_dns_server_blocking()
376400
let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
377401

378402
let child = std::process::Command::new("ip")
@@ -382,27 +406,48 @@ impl LinuxJail {
382406
&namespace_name,
383407
exe_path.to_str().context("Invalid executable path")?,
384408
"--__internal-dns-server",
409+
&host_ip_str,
385410
])
386411
.stdin(std::process::Stdio::null())
387412
.stdout(std::process::Stdio::null())
388413
.stderr(std::process::Stdio::null())
389414
.spawn()
390415
.context("Failed to spawn in-namespace DNS server")?;
391416

392-
info!("Started in-namespace DNS server (pid {})", child.id());
393-
417+
info!("Started namespace DNS server (pid {})", child.id());
394418
self.dns_server_child = Some(child);
395419

420+
// 2. Start host DNS server for DNAT redirection
421+
info!(
422+
"Starting host DNS server on {} for DNAT redirection",
423+
host_ip_str
424+
);
425+
426+
let mut host_server = dns::DummyDnsServer::new();
427+
let host_addr = format!("{}:53", host_ip_str);
428+
host_server
429+
.start(&host_addr)
430+
.context("Failed to start host DNS server")?;
431+
432+
info!("Started host DNS server on {}", host_addr);
433+
self.host_dns_server = Some(host_server);
434+
396435
Ok(())
397436
}
398437

399-
/// Stop the DNS server
438+
/// Stop the DNS servers
400439
fn stop_dns_server(&mut self) {
440+
// Stop namespace DNS server process
401441
if let Some(mut child) = self.dns_server_child.take() {
402-
debug!("Stopping in-namespace DNS server (pid {})", child.id());
442+
debug!("Stopping namespace DNS server (pid {})", child.id());
403443
let _ = child.kill();
404444
let _ = child.wait();
405-
debug!("Stopped in-namespace DNS server");
445+
debug!("Stopped namespace DNS server");
446+
}
447+
448+
// Stop host DNS server (runs in threads, cleanup on drop)
449+
if let Some(_server) = self.host_dns_server.take() {
450+
debug!("Stopping host DNS server (cleanup on drop)");
406451
}
407452
}
408453
}
@@ -580,6 +625,7 @@ impl Clone for LinuxJail {
580625
veth_pair: None,
581626
nftables: None,
582627
dns_server_child: None,
628+
host_dns_server: None,
583629
host_ip: self.host_ip,
584630
host_cidr: self.host_cidr.clone(),
585631
guest_cidr: self.guest_cidr.clone(),

src/jail/linux/nftables.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ table ip {table_name} {{
129129
130130
# Redirect all DNS queries to our dummy DNS server running in namespace
131131
# This works regardless of what nameserver is in /etc/resolv.conf
132-
udp dport 53 dnat to 127.0.0.1:53
132+
# We redirect to host_ip instead of 127.0.0.1 because DNAT to localhost
133+
# doesn't work reliably in the OUTPUT chain for locally-generated packets
134+
udp dport 53 dnat to {host_ip}:53
133135
134136
# Redirect HTTP to proxy running on host
135137
tcp dport 80 dnat to {host_ip}:{http_port}
@@ -145,16 +147,21 @@ table ip {table_name} {{
145147
# Always allow established/related traffic
146148
ct state established,related accept
147149
148-
# Allow DNS traffic to localhost (after DNAT redirection)
149-
ip daddr 127.0.0.1 udp dport 53 accept
150+
# Allow DNS traffic to host IP (after DNAT redirection for external nameservers)
151+
ip daddr {host_ip} udp dport 53 accept
152+
153+
# Allow DNS traffic to loopback addresses (systemd-resolved, etc.)
154+
# These are handled by the namespace DNS server and don't need DNAT
155+
ip daddr 127.0.0.53 udp dport 53 accept
156+
ip daddr 127.0.0.54 udp dport 53 accept
150157
151158
# Allow traffic to the host proxy ports after DNAT
152159
ip daddr {host_ip} tcp dport {{ {http_port}, {https_port} }} accept
153160
154161
# Explicitly block all other UDP (e.g., QUIC on 443)
155162
# This must come AFTER allowing DNS traffic
156163
ip protocol udp drop
157-
164+
158165
# Explicitly block all other TCP traffic
159166
# This must come AFTER allowing HTTP/HTTPS traffic
160167
ip protocol tcp drop

src/main.rs

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -309,17 +309,25 @@ async fn main() -> Result<()> {
309309
// Handle internal DNS server flag (must be first, before clap parsing)
310310
// This is called by LinuxJail when spawning DNS server inside namespace
311311
#[cfg(target_os = "linux")]
312-
if std::env::args().any(|arg| arg == "--__internal-dns-server") {
313-
use httpjail::jail::linux::dns::run_dns_server_blocking;
314-
315-
// Bring up loopback interface in the namespace
316-
std::process::Command::new("ip")
317-
.args(["link", "set", "lo", "up"])
318-
.output()
319-
.context("Failed to bring up loopback interface")?;
320-
321-
// Run the DNS server (blocks forever)
322-
return run_dns_server_blocking();
312+
{
313+
let args: Vec<String> = std::env::args().collect();
314+
if let Some(pos) = args.iter().position(|arg| arg == "--__internal-dns-server") {
315+
use httpjail::jail::linux::dns::run_dns_server_blocking;
316+
317+
// Get host IP from next argument
318+
let host_ip = args
319+
.get(pos + 1)
320+
.context("Missing host IP argument after --__internal-dns-server")?;
321+
322+
// Bring up loopback interface in the namespace
323+
std::process::Command::new("ip")
324+
.args(["link", "set", "lo", "up"])
325+
.output()
326+
.context("Failed to bring up loopback interface")?;
327+
328+
// Run the DNS server (blocks forever)
329+
return run_dns_server_blocking(host_ip);
330+
}
323331
}
324332

325333
let args = Args::parse();

0 commit comments

Comments
 (0)