Skip to content

Commit 4e561eb

Browse files
committed
fix(cluster): resolve DNS failures on systemd-resolved hosts
Docker's embedded DNS at 127.0.0.11 is only reachable from the container's own network namespace. k3s pods in child namespaces cannot reach it, causing silent DNS failures on Ubuntu and other systemd-resolved hosts where /etc/resolv.conf contains 127.0.0.53. Sniff upstream DNS resolvers from the host in the Rust bootstrap crate by reading /run/systemd/resolve/resolv.conf (systemd-resolved only — intentionally does NOT read /etc/resolv.conf to avoid bypassing Docker Desktop's DNAT proxy on macOS/Windows). Filter loopback addresses (127.x.x.x and ::1) and pass the result to the container as the UPSTREAM_DNS env var. Skip DNS sniffing for remote deploys where the local host's resolvers would be wrong. The entrypoint checks UPSTREAM_DNS first, falling back to /etc/resolv.conf inside the container for manual launches. This follows the existing pattern used by registry config, SSH gateway, GPU support, and image tags. Closes #437 Signed-off-by: Brian Taylor <brian.taylor818@gmail.com>
1 parent bbcaed2 commit 4e561eb

File tree

4 files changed

+471
-0
lines changed

4 files changed

+471
-0
lines changed

crates/openshell-bootstrap/src/docker.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,57 @@ fn home_dir() -> Option<String> {
236236
std::env::var("HOME").ok()
237237
}
238238

239+
/// Discover upstream DNS resolvers from systemd-resolved's configuration.
240+
///
241+
/// Only reads `/run/systemd/resolve/resolv.conf` — the upstream resolver file
242+
/// maintained by systemd-resolved. This file is only present on Linux hosts
243+
/// running systemd-resolved (e.g., Ubuntu), so the function is a no-op on
244+
/// macOS, Windows/WSL, and non-systemd Linux distributions.
245+
///
246+
/// We intentionally do NOT fall back to `/etc/resolv.conf` here. On Docker
247+
/// Desktop (macOS/Windows), `/etc/resolv.conf` may contain non-loopback
248+
/// resolvers that appear valid but are unreachable via direct UDP from inside
249+
/// the container's network stack. Those environments rely on the entrypoint's
250+
/// iptables DNAT proxy to Docker's embedded DNS — sniffing host resolvers
251+
/// would bypass that proxy and break DNS.
252+
///
253+
/// Returns an empty vec if no usable resolvers are found.
254+
fn resolve_upstream_dns() -> Vec<String> {
255+
let paths = ["/run/systemd/resolve/resolv.conf"];
256+
257+
for path in &paths {
258+
if let Ok(contents) = std::fs::read_to_string(path) {
259+
let resolvers: Vec<String> = contents
260+
.lines()
261+
.filter_map(|line| {
262+
let line = line.trim();
263+
if !line.starts_with("nameserver") {
264+
return None;
265+
}
266+
let ip = line.split_whitespace().nth(1)?;
267+
if ip.starts_with("127.") || ip == "::1" {
268+
return None;
269+
}
270+
Some(ip.to_string())
271+
})
272+
.collect();
273+
274+
if !resolvers.is_empty() {
275+
tracing::debug!(
276+
"Discovered {} upstream DNS resolver(s) from {}: {}",
277+
resolvers.len(),
278+
path,
279+
resolvers.join(", "),
280+
);
281+
return resolvers;
282+
}
283+
}
284+
}
285+
286+
tracing::debug!("No upstream DNS resolvers found in host resolver config");
287+
Vec::new()
288+
}
289+
239290
/// Create an SSH Docker client from remote options.
240291
pub async fn create_ssh_docker_client(remote: &RemoteOptions) -> Result<Docker> {
241292
// Ensure destination has ssh:// prefix
@@ -455,6 +506,7 @@ pub async fn ensure_container(
455506
registry_username: Option<&str>,
456507
registry_token: Option<&str>,
457508
gpu: bool,
509+
is_remote: bool,
458510
) -> Result<()> {
459511
let container_name = container_name(name);
460512

@@ -675,6 +727,17 @@ pub async fn ensure_container(
675727
env_vars.push("GPU_ENABLED=true".to_string());
676728
}
677729

730+
// Pass upstream DNS resolvers discovered on the host so the entrypoint
731+
// can configure k3s without probing files inside the container.
732+
// Skip for remote deploys — the local host's resolvers are likely wrong
733+
// for the remote Docker host (different network, split-horizon DNS, etc.).
734+
if !is_remote {
735+
let upstream_dns = resolve_upstream_dns();
736+
if !upstream_dns.is_empty() {
737+
env_vars.push(format!("UPSTREAM_DNS={}", upstream_dns.join(",")));
738+
}
739+
}
740+
678741
let env = Some(env_vars);
679742

680743
let config = ContainerCreateBody {
@@ -1195,4 +1258,112 @@ mod tests {
11951258
"should return a reasonable number of sockets"
11961259
);
11971260
}
1261+
1262+
#[test]
1263+
fn resolve_upstream_dns_filters_loopback() {
1264+
// This test validates the function runs without panic on the current host.
1265+
// The exact output depends on the host's DNS config, but loopback
1266+
// addresses must never appear in the result.
1267+
let resolvers = resolve_upstream_dns();
1268+
for r in &resolvers {
1269+
assert!(
1270+
!r.starts_with("127."),
1271+
"IPv4 loopback should be filtered: {r}"
1272+
);
1273+
assert_ne!(r, "::1", "IPv6 loopback should be filtered");
1274+
}
1275+
}
1276+
1277+
#[test]
1278+
fn resolve_upstream_dns_returns_vec() {
1279+
// Verify the function returns a vec (may be empty in some CI environments
1280+
// where no resolv.conf exists, but should never panic).
1281+
let resolvers = resolve_upstream_dns();
1282+
assert!(
1283+
resolvers.len() <= 20,
1284+
"should return a reasonable number of resolvers"
1285+
);
1286+
}
1287+
1288+
/// Helper: parse resolv.conf content using the same logic as resolve_upstream_dns().
1289+
/// Allows deterministic testing without depending on host DNS config.
1290+
fn parse_resolv_conf(contents: &str) -> Vec<String> {
1291+
contents
1292+
.lines()
1293+
.filter_map(|line| {
1294+
let line = line.trim();
1295+
if !line.starts_with("nameserver") {
1296+
return None;
1297+
}
1298+
let ip = line.split_whitespace().nth(1)?;
1299+
if ip.starts_with("127.") || ip == "::1" {
1300+
return None;
1301+
}
1302+
Some(ip.to_string())
1303+
})
1304+
.collect()
1305+
}
1306+
1307+
#[test]
1308+
fn parse_resolv_conf_filters_ipv4_loopback() {
1309+
let input = "nameserver 127.0.0.1\nnameserver 127.0.0.53\nnameserver 127.0.0.11\n";
1310+
assert!(parse_resolv_conf(input).is_empty());
1311+
}
1312+
1313+
#[test]
1314+
fn parse_resolv_conf_filters_ipv6_loopback() {
1315+
let input = "nameserver ::1\n";
1316+
assert!(parse_resolv_conf(input).is_empty());
1317+
}
1318+
1319+
#[test]
1320+
fn parse_resolv_conf_passes_real_resolvers() {
1321+
let input = "nameserver 8.8.8.8\nnameserver 1.1.1.1\n";
1322+
assert_eq!(parse_resolv_conf(input), vec!["8.8.8.8", "1.1.1.1"]);
1323+
}
1324+
1325+
#[test]
1326+
fn parse_resolv_conf_mixed_loopback_and_real() {
1327+
let input =
1328+
"nameserver 127.0.0.53\nnameserver ::1\nnameserver 10.0.0.1\nnameserver 172.16.0.1\n";
1329+
assert_eq!(parse_resolv_conf(input), vec!["10.0.0.1", "172.16.0.1"]);
1330+
}
1331+
1332+
#[test]
1333+
fn parse_resolv_conf_ignores_comments_and_other_lines() {
1334+
let input =
1335+
"# nameserver 8.8.8.8\nsearch example.com\noptions ndots:5\nnameserver 1.1.1.1\n";
1336+
assert_eq!(parse_resolv_conf(input), vec!["1.1.1.1"]);
1337+
}
1338+
1339+
#[test]
1340+
fn parse_resolv_conf_handles_tabs_and_extra_spaces() {
1341+
let input = "nameserver\t8.8.8.8\nnameserver 1.1.1.1\n";
1342+
assert_eq!(parse_resolv_conf(input), vec!["8.8.8.8", "1.1.1.1"]);
1343+
}
1344+
1345+
#[test]
1346+
fn parse_resolv_conf_empty_input() {
1347+
assert!(parse_resolv_conf("").is_empty());
1348+
assert!(parse_resolv_conf(" \n\n").is_empty());
1349+
}
1350+
1351+
#[test]
1352+
fn parse_resolv_conf_bare_nameserver_keyword() {
1353+
assert!(parse_resolv_conf("nameserver\n").is_empty());
1354+
assert!(parse_resolv_conf("nameserver \n").is_empty());
1355+
}
1356+
1357+
#[test]
1358+
fn parse_resolv_conf_systemd_resolved_typical() {
1359+
let input =
1360+
"# This is /run/systemd/resolve/resolv.conf\nnameserver 192.168.1.1\nsearch lan\n";
1361+
assert_eq!(parse_resolv_conf(input), vec!["192.168.1.1"]);
1362+
}
1363+
1364+
#[test]
1365+
fn parse_resolv_conf_crlf_line_endings() {
1366+
let input = "nameserver 8.8.8.8\r\nnameserver 1.1.1.1\r\n";
1367+
assert_eq!(parse_resolv_conf(input), vec!["8.8.8.8", "1.1.1.1"]);
1368+
}
11981369
}

crates/openshell-bootstrap/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ where
417417
registry_username.as_deref(),
418418
registry_token.as_deref(),
419419
gpu,
420+
remote_opts.is_some(),
420421
)
421422
.await?;
422423
start_container(&target_docker, &name).await?;

deploy/docker/cluster-entrypoint.sh

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,46 @@ wait_for_default_route() {
6969
# 3. Adding DNAT rules so traffic to <eth0_ip>:53 reaches Docker's DNS
7070
# 4. Writing that IP into the k3s resolv.conf
7171

72+
# Extract upstream DNS resolvers reachable from k3s pod namespaces.
73+
# Docker's embedded DNS (127.0.0.11) is namespace-local — DNAT to it from
74+
# pod traffic is dropped as a martian packet. Use real upstream servers instead.
75+
#
76+
# Priority:
77+
# 1. UPSTREAM_DNS env var (set by bootstrap, comma-separated)
78+
# 2. /etc/resolv.conf (fallback for non-bootstrap launches)
79+
get_upstream_resolvers() {
80+
local resolvers=""
81+
82+
# Bootstrap-provided resolvers (sniffed from host by the Rust bootstrap crate)
83+
if [ -n "${UPSTREAM_DNS:-}" ]; then
84+
resolvers=$(printf '%s\n' "$UPSTREAM_DNS" | tr ',' '\n' | \
85+
awk '{ip=$1; if(ip !~ /^127\./ && ip != "::1" && ip != "") print ip}')
86+
fi
87+
88+
# Fallback: Docker-generated resolv.conf may have non-loopback servers
89+
if [ -z "$resolvers" ]; then
90+
resolvers=$(awk '/^nameserver/{ip=$2; gsub(/\r/,"",ip); if(ip !~ /^127\./ && ip != "::1") print ip}' \
91+
/etc/resolv.conf)
92+
fi
93+
94+
echo "$resolvers"
95+
}
96+
7297
setup_dns_proxy() {
98+
# Prefer upstream resolvers that work across network namespaces.
99+
# This avoids the DNAT-to-loopback problem on systemd-resolved hosts.
100+
UPSTREAM_DNS=$(get_upstream_resolvers)
101+
if [ -n "$UPSTREAM_DNS" ]; then
102+
: > "$RESOLV_CONF"
103+
echo "$UPSTREAM_DNS" | while read -r ns; do
104+
[ -n "$ns" ] && echo "nameserver $ns" >> "$RESOLV_CONF"
105+
done
106+
echo "DNS: using upstream resolvers directly (avoids cross-namespace DNAT)"
107+
cat "$RESOLV_CONF"
108+
return 0
109+
fi
110+
111+
# Fall back to DNAT proxy when no upstream resolvers are available.
73112
# Extract Docker's actual DNS listener ports from the DOCKER_OUTPUT chain.
74113
# Docker sets up rules like:
75114
# -A DOCKER_OUTPUT -d 127.0.0.11/32 -p udp --dport 53 -j DNAT --to-destination 127.0.0.11:<port>
@@ -160,6 +199,8 @@ verify_dns() {
160199
sleep 1
161200
i=$((i + 1))
162201
done
202+
echo "Warning: DNS verification failed for $lookup_host after $attempts attempts"
203+
echo " resolv.conf: $(head -3 "$RESOLV_CONF" 2>/dev/null)"
163204
return 1
164205
}
165206

0 commit comments

Comments
 (0)