@@ -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.
240291pub 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\n nameserver 127.0.0.53\n nameserver 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\n nameserver 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\n nameserver ::1\n nameserver 10.0.0.1\n nameserver 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\n search example.com\n options ndots:5\n nameserver 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\t 8.8.8.8\n nameserver 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\n nameserver 192.168.1.1\n search 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 \n nameserver 1.1.1.1\r \n " ;
1367+ assert_eq ! ( parse_resolv_conf( input) , vec![ "8.8.8.8" , "1.1.1.1" ] ) ;
1368+ }
11981369}
0 commit comments