Skip to content

Commit 6ee622b

Browse files
committed
fix(cluster): filter IPv6 loopback from upstream DNS resolvers
The awk filter in get_upstream_resolvers() only rejected IPv4 loopback (127.x.x.x) but allowed IPv6 loopback (::1) through. On hosts where resolv.conf contains "nameserver ::1", this would write an unreachable resolver into the k3s resolv.conf, causing the same silent DNS failure the parent commit fixes for 127.0.0.53. Add "::1" to the awk reject list and add a test suite for the resolver filter covering IPv4/IPv6 loopback, mixed configs, edge cases, and command injection resistance. Signed-off-by: Brian Taylor <brian.taylor818@gmail.com>
1 parent e2ebab7 commit 6ee622b

File tree

2 files changed

+217
-2
lines changed

2 files changed

+217
-2
lines changed

deploy/docker/cluster-entrypoint.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,13 @@ get_upstream_resolvers() {
7777

7878
# systemd-resolved upstream config (mounted from host when available)
7979
if [ -f /run/systemd/resolve/resolv.conf ]; then
80-
resolvers=$(awk '/^nameserver/{ip=$2; if(ip !~ /^127\./) print ip}' \
80+
resolvers=$(awk '/^nameserver/{ip=$2; if(ip !~ /^127\./ && ip != "::1") print ip}' \
8181
/run/systemd/resolve/resolv.conf)
8282
fi
8383

8484
# Docker-generated resolv.conf may have non-loopback servers
8585
if [ -z "$resolvers" ]; then
86-
resolvers=$(awk '/^nameserver/{ip=$2; if(ip !~ /^127\./) print ip}' \
86+
resolvers=$(awk '/^nameserver/{ip=$2; if(ip !~ /^127\./ && ip != "::1") print ip}' \
8787
/etc/resolv.conf)
8888
fi
8989

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/bin/sh
2+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Unit tests for the DNS resolver extraction logic in cluster-entrypoint.sh.
6+
#
7+
# Validates that get_upstream_resolvers() correctly filters loopback addresses
8+
# (IPv4 127.x.x.x, IPv6 ::1) and passes through real upstream nameservers.
9+
#
10+
# Usage: sh deploy/docker/tests/test-dns-resolvers.sh
11+
12+
set -eu
13+
14+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15+
16+
_PASS=0
17+
_FAIL=0
18+
19+
pass() {
20+
_PASS=$((_PASS + 1))
21+
printf ' PASS: %s\n' "$1"
22+
}
23+
24+
fail() {
25+
_FAIL=$((_FAIL + 1))
26+
printf ' FAIL: %s\n' "$1" >&2
27+
if [ -n "${2:-}" ]; then
28+
printf ' %s\n' "$2" >&2
29+
fi
30+
}
31+
32+
assert_eq() {
33+
_actual="$1"
34+
_expected="$2"
35+
_label="$3"
36+
37+
if [ "$_actual" = "$_expected" ]; then
38+
pass "$_label"
39+
else
40+
fail "$_label" "expected '$_expected', got '$_actual'"
41+
fi
42+
}
43+
44+
assert_contains() {
45+
_haystack="$1"
46+
_needle="$2"
47+
_label="$3"
48+
49+
if printf '%s' "$_haystack" | grep -qF "$_needle"; then
50+
pass "$_label"
51+
else
52+
fail "$_label" "expected '$_needle' in output '$_haystack'"
53+
fi
54+
}
55+
56+
assert_not_contains() {
57+
_haystack="$1"
58+
_needle="$2"
59+
_label="$3"
60+
61+
if printf '%s' "$_haystack" | grep -qF "$_needle"; then
62+
fail "$_label" "unexpected '$_needle' found in output '$_haystack'"
63+
else
64+
pass "$_label"
65+
fi
66+
}
67+
68+
assert_empty() {
69+
_val="$1"
70+
_label="$2"
71+
72+
if [ -z "$_val" ]; then
73+
pass "$_label"
74+
else
75+
fail "$_label" "expected empty, got '$_val'"
76+
fi
77+
}
78+
79+
# The awk filter extracted from cluster-entrypoint.sh. Tested in isolation
80+
# so we don't need root, iptables, or a running container.
81+
filter_resolvers() {
82+
awk '/^nameserver/{ip=$2; if(ip !~ /^127\./ && ip != "::1") print ip}'
83+
}
84+
85+
# ---------------------------------------------------------------------------
86+
# Tests
87+
# ---------------------------------------------------------------------------
88+
89+
test_filters_ipv4_loopback() {
90+
printf 'TEST: filters IPv4 loopback addresses\n'
91+
92+
input="nameserver 127.0.0.1
93+
nameserver 127.0.0.11
94+
nameserver 127.0.0.53
95+
nameserver 127.1.2.3"
96+
result=$(printf '%s\n' "$input" | filter_resolvers)
97+
assert_empty "$result" "all 127.x.x.x addresses filtered"
98+
}
99+
100+
test_filters_ipv6_loopback() {
101+
printf 'TEST: filters IPv6 loopback address\n'
102+
103+
input="nameserver ::1"
104+
result=$(printf '%s\n' "$input" | filter_resolvers)
105+
assert_empty "$result" "::1 filtered"
106+
}
107+
108+
test_passes_real_ipv4() {
109+
printf 'TEST: passes real IPv4 nameservers\n'
110+
111+
input="nameserver 8.8.8.8
112+
nameserver 8.8.4.4
113+
nameserver 1.1.1.1"
114+
result=$(printf '%s\n' "$input" | filter_resolvers)
115+
assert_contains "$result" "8.8.8.8" "passes 8.8.8.8"
116+
assert_contains "$result" "8.8.4.4" "passes 8.8.4.4"
117+
assert_contains "$result" "1.1.1.1" "passes 1.1.1.1"
118+
}
119+
120+
test_passes_real_ipv6() {
121+
printf 'TEST: passes real IPv6 nameservers\n'
122+
123+
input="nameserver 2001:4860:4860::8888
124+
nameserver fd00::1"
125+
result=$(printf '%s\n' "$input" | filter_resolvers)
126+
assert_contains "$result" "2001:4860:4860::8888" "passes Google IPv6 DNS"
127+
assert_contains "$result" "fd00::1" "passes ULA IPv6 address"
128+
}
129+
130+
test_mixed_loopback_and_real() {
131+
printf 'TEST: filters loopback, keeps real in mixed config\n'
132+
133+
input="nameserver 127.0.0.53
134+
nameserver ::1
135+
nameserver 10.0.0.1
136+
nameserver 172.16.0.1"
137+
result=$(printf '%s\n' "$input" | filter_resolvers)
138+
assert_not_contains "$result" "127.0.0.53" "127.0.0.53 filtered"
139+
assert_not_contains "$result" "::1" "::1 filtered"
140+
assert_contains "$result" "10.0.0.1" "10.0.0.1 kept"
141+
assert_contains "$result" "172.16.0.1" "172.16.0.1 kept"
142+
}
143+
144+
test_systemd_resolved_typical() {
145+
printf 'TEST: typical systemd-resolved upstream config\n'
146+
147+
# /run/systemd/resolve/resolv.conf typically looks like this
148+
input="# This is /run/systemd/resolve/resolv.conf managed by man:systemd-resolved(8).
149+
nameserver 192.168.1.1
150+
search lan"
151+
result=$(printf '%s\n' "$input" | filter_resolvers)
152+
assert_eq "$result" "192.168.1.1" "extracts router DNS from systemd-resolved"
153+
}
154+
155+
test_docker_embedded_dns() {
156+
printf 'TEST: Docker embedded DNS (127.0.0.11) filtered\n'
157+
158+
input="nameserver 127.0.0.11
159+
search openshell_default"
160+
result=$(printf '%s\n' "$input" | filter_resolvers)
161+
assert_empty "$result" "Docker 127.0.0.11 filtered"
162+
}
163+
164+
test_ignores_non_nameserver_lines() {
165+
printf 'TEST: ignores comments, search, options lines\n'
166+
167+
input="# nameserver 8.8.8.8
168+
search example.com
169+
options ndots:5
170+
nameserver 1.1.1.1"
171+
result=$(printf '%s\n' "$input" | filter_resolvers)
172+
assert_eq "$result" "1.1.1.1" "only real nameserver line extracted"
173+
}
174+
175+
test_empty_input() {
176+
printf 'TEST: empty input returns empty\n'
177+
178+
result=$(printf '' | filter_resolvers)
179+
assert_empty "$result" "empty input produces empty output"
180+
}
181+
182+
test_no_command_injection() {
183+
printf 'TEST: malicious resolv.conf entries are not executed\n'
184+
185+
# These should be extracted as literal strings by awk, not executed
186+
input='nameserver $(rm -rf /)
187+
nameserver 8.8.8.8
188+
nameserver ; echo pwned
189+
nameserver `id`'
190+
result=$(printf '%s\n' "$input" | filter_resolvers)
191+
# awk $2 splits on whitespace: "$(rm" is $2 for line 1, ";" for line 3
192+
# None of these are executed — they're just strings
193+
assert_contains "$result" "8.8.8.8" "real resolver preserved"
194+
assert_not_contains "$result" "pwned" "no command injection"
195+
}
196+
197+
# ---------------------------------------------------------------------------
198+
# Run all tests
199+
# ---------------------------------------------------------------------------
200+
201+
printf '=== DNS resolver filter tests ===\n\n'
202+
203+
test_filters_ipv4_loopback
204+
test_filters_ipv6_loopback
205+
test_passes_real_ipv4
206+
test_passes_real_ipv6
207+
test_mixed_loopback_and_real
208+
test_systemd_resolved_typical
209+
test_docker_embedded_dns
210+
test_ignores_non_nameserver_lines
211+
test_empty_input
212+
test_no_command_injection
213+
214+
printf '\n=== Results: %d passed, %d failed ===\n' "$_PASS" "$_FAIL"
215+
[ "$_FAIL" -eq 0 ]

0 commit comments

Comments
 (0)