Skip to content

Commit 0079604

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 (reading /run/systemd/resolve/resolv.conf then /etc/resolv.conf), filter loopback addresses (127.x.x.x and ::1), and pass the result to the container as the UPSTREAM_DNS env var. The entrypoint checks this env var first, falling back to /etc/resolv.conf for manual container launches. This follows the existing pattern used by registry config, SSH gateway, GPU support, and image tags: the bootstrap discovers host state and passes it as env vars. Closes #437 Signed-off-by: Brian Taylor <brian.taylor818@gmail.com>
1 parent 3566e55 commit 0079604

File tree

3 files changed

+457
-0
lines changed

3 files changed

+457
-0
lines changed

crates/openshell-bootstrap/src/docker.rs

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

239+
/// Discover upstream DNS resolvers from the host's resolver configuration.
240+
///
241+
/// Reads systemd-resolved's upstream config first (`/run/systemd/resolve/resolv.conf`),
242+
/// then falls back to `/etc/resolv.conf`. Filters out loopback addresses (127.x.x.x
243+
/// and ::1) that are unreachable from k3s pod network namespaces.
244+
///
245+
/// Returns an empty vec if no usable resolvers are found.
246+
fn resolve_upstream_dns() -> Vec<String> {
247+
let paths = ["/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"];
248+
249+
for path in &paths {
250+
if let Ok(contents) = std::fs::read_to_string(path) {
251+
let resolvers: Vec<String> = contents
252+
.lines()
253+
.filter_map(|line| {
254+
let line = line.trim();
255+
if !line.starts_with("nameserver") {
256+
return None;
257+
}
258+
let ip = line.split_whitespace().nth(1)?;
259+
if ip.starts_with("127.") || ip == "::1" {
260+
return None;
261+
}
262+
Some(ip.to_string())
263+
})
264+
.collect();
265+
266+
if !resolvers.is_empty() {
267+
tracing::debug!(
268+
"Discovered {} upstream DNS resolver(s) from {}: {}",
269+
resolvers.len(),
270+
path,
271+
resolvers.join(", "),
272+
);
273+
return resolvers;
274+
}
275+
}
276+
}
277+
278+
tracing::debug!("No upstream DNS resolvers found in host resolver config");
279+
Vec::new()
280+
}
281+
239282
/// Create an SSH Docker client from remote options.
240283
pub async fn create_ssh_docker_client(remote: &RemoteOptions) -> Result<Docker> {
241284
// Ensure destination has ssh:// prefix
@@ -675,6 +718,13 @@ pub async fn ensure_container(
675718
env_vars.push("GPU_ENABLED=true".to_string());
676719
}
677720

721+
// Pass upstream DNS resolvers discovered on the host so the entrypoint
722+
// can configure k3s without probing files inside the container.
723+
let upstream_dns = resolve_upstream_dns();
724+
if !upstream_dns.is_empty() {
725+
env_vars.push(format!("UPSTREAM_DNS={}", upstream_dns.join(",")));
726+
}
727+
678728
let env = Some(env_vars);
679729

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

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)