From d9df1120a3a783203f20be5de54dc37a439661e8 Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Sun, 4 Oct 2026 06:14:39 -0700 Subject: [PATCH 1/3] Fetch directly in test_ipv6_egress_to_host, not through the host's proxy The test starts an IPv6-only HTTP server on the host and has the guest fetch it with wget at the host's global IPv6 address. fcvm forwards the host's http_proxy to the guest and wget obeys it, so on a host that exports a proxy the request went to the proxy and never to the server. On an IPv6-only host whose proxy refuses the host's own addresses the test failed on every run: wget exited 8 on the proxy's "403 Forbidden", the whole exec returned in about 60 ms, and -q left no trace of the reason. Measured on that host with one VM, a capture on the namespace bridge, a capture on the server's port and strace of fcvm's connect() calls: - old command: fcvm's egress proxy connected to the proxy's port 8080 and the proxy answered 403. No packet reached the server's port and no IPv6 TCP crossed the bridge. - old command with the proxy variables unset in the guest, which is what a guest has on a host that exports none: 200 from the server. - old command with --no-proxy: fcvm connected to the host address on the server's port, 200 from the server. The fetch now passes --no-proxy. It also sets http_proxy to a port nothing listens on, so a fetch that obeys a proxy fails on every host and not only on one that exports a proxy, and it uses -nv in place of -q so that wget's error line is in the failure. The failure message no longer names pasta: the guest's rules redirect TCP for a global address to fcvm's egress proxy, so pasta does not carry this connection. Tested on the IPv6-only host, proxy exported: make _test-root FILTER="-p fcvm --test test_rootless_ipv6 -E 'test(=test_ipv6_egress_to_host)'" STREAM=1 before: TRY 2 FAIL [ 18.280s] (1/1) fcvm::test_rootless_ipv6 test_ipv6_egress_to_host with the closed-port http_proxy and without --no-proxy: failed: Connection refused. TRY 2 FAIL [ 18.384s] (1/1) fcvm::test_rootless_ipv6 test_ipv6_egress_to_host after: PASS [ 18.337s] (1/1) fcvm::test_rootless_ipv6 test_ipv6_egress_to_host --- tests/test_rootless_ipv6.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/test_rootless_ipv6.rs b/tests/test_rootless_ipv6.rs index 0c61b4c7e..002eb6243 100644 --- a/tests/test_rootless_ipv6.rs +++ b/tests/test_rootless_ipv6.rs @@ -310,19 +310,38 @@ with IPv6Server(('::', {}), handler) as httpd: println!("VM is healthy, testing IPv6 egress to host..."); - // Try to reach the IPv6-only server from the VM - // Use wget since it's available in alpine (curl would need to be installed) + // Fetch from the IPv6-only server with the guest's wget (GNU wget in the guest OS). let url = format!("http://[{}]:{}/", host_ipv6, server_port); println!("Attempting to connect to: {}", url); - let result = common::exec_in_vm(pid, &["wget", "-q", "-O", "-", "--timeout=5", &url]).await; + // The request has to reach the server above, so wget must not hand it to a proxy. + // fcvm forwards the host's proxy variables to the guest and wget obeys http_proxy: on + // a host that exports one, the request goes to that proxy and no packet reaches the + // server (a proxy that refuses the host's address answers HTTP 403). --no-proxy keeps + // the connection direct. http_proxy names a port nothing listens on, so a fetch that + // does obey a proxy fails on every host and not only on one that exports a proxy. + // -nv keeps wget's one-line error in the failure, where -q left only an exit status. + let result = common::exec_in_vm( + pid, + &[ + "http_proxy=http://[::1]:9/", + "wget", + "--no-proxy", + "-nv", + "-O", + "-", + "--timeout=5", + &url, + ], + ) + .await; // Clean up server.kill().await.ok(); common::kill_process(pid).await; let _ = child.wait().await; - let output = result.context("IPv6 egress failed - pasta should support native IPv6")?; + let output = result.context("the guest could not fetch from the host's IPv6-only server")?; println!("✓ IPv6 egress works! Server response:\n{}", output); Ok(()) } From e205db1b91d480c697133785e69cfa7f4978b724 Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Sun, 4 Oct 2026 06:34:26 -0700 Subject: [PATCH 2/3] Run test_ipv6_egress_to_host's host server inside the test process The test started its HTTP server as a python3 child and stopped it with one kill() after the fetch. The server could outlive the test in two ways: - A return before that line (a failed VM spawn, any `?`) skipped the kill, on every host. Nothing else stopped the child. - Where python3 is a launcher that runs the interpreter as its own child, the kill took the launcher and left the interpreter listening. The server exits by itself only after it has served a request, so on such a host a failing run left one server per try. A parent-death signal on the child would not have covered the second case. Measured on a launcher host: with the signal armed on the launcher and its parent killed, the launcher died and the interpreter stayed. Killing the launcher's process group stopped both, and nothing can run a group kill for a test process that is itself killed. The server is now common::LocalTestServer, a task of the test process, which test_egress.rs and test_proxy.rs already use. It stops when its handle is dropped and it cannot outlive the test process, so no path has a child to tear down. With a server whose body is known, the test also checks that the fetched body is that server's. The port is now the one the server bound, where the test used to pick a free port and release it for python3 to bind later, and the test no longer prints every listening socket on the host. host_http_server_stops_when_its_test_returns_early starts the server the way the test does, in a body that fails before its cleanup, and requires the port to be free afterwards. Tested: make _test-root FILTER="-p fcvm --test test_rootless_ipv6 -E ''" STREAM=1 The new test with the python3 child that only the explicit kill stops: Error: a server still listens on port 29308 5s after its test returned: Address already in use (os error 98) TRY 2 FAIL [ 5.135s] (1/1) fcvm::test_rootless_ipv6 host_http_server_stops_when_its_test_returns_early With the change: PASS [ 0.051s] (1/2) fcvm::test_rootless_ipv6 host_http_server_stops_when_its_test_returns_early PASS [ 16.389s] (2/2) fcvm::test_rootless_ipv6 test_ipv6_egress_to_host test_ipv6_egress_to_host made to fail (--no-proxy removed) on a launcher host: before the change each try left a python server listening; with it both tries fail and nothing listens on either server's port afterwards. --- tests/test_rootless_ipv6.rs | 109 +++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/tests/test_rootless_ipv6.rs b/tests/test_rootless_ipv6.rs index 002eb6243..de618b840 100644 --- a/tests/test_rootless_ipv6.rs +++ b/tests/test_rootless_ipv6.rs @@ -198,15 +198,26 @@ async fn test_ipv6_connectivity_in_vm() -> Result<()> { Ok(()) } -/// Test IPv6 egress from VM to an IPv6-only server on the host. +/// HTTP server for a guest to fetch from, on the host's IPv6 wildcard address. +/// +/// It is a task of the test process and not a child process, so it stops when its handle +/// is dropped (an early return or a panic in the test) and it cannot outlive a test +/// process that is killed. A child process needs its own teardown on each of those paths, +/// and where `python3` is a launcher that runs the interpreter as its own child, neither +/// killing the child nor a parent-death signal on it reaches the interpreter. +async fn start_host_http_server() -> Result { + common::LocalTestServer::start_on_available_port("::") + .await + .context("starting the host's IPv6 HTTP server") +} + +/// Test IPv6 egress from VM to a server on the host's IPv6 address. /// /// This verifies that the VM can reach external IPv6 endpoints. -/// We start a simple HTTP server on the host listening ONLY on IPv6, -/// then have the VM try to connect to it. +/// We start an HTTP server on the host's IPv6 wildcard address, +/// then have the VM fetch from it at the host's global IPv6 address. #[tokio::test] async fn test_ipv6_egress_to_host() -> Result<()> { - use std::time::Duration; - // Get host's global IPv6 address let ip_output = tokio::process::Command::new("ip") .args(["-6", "addr", "show", "scope", "global"]) @@ -241,46 +252,8 @@ async fn test_ipv6_egress_to_host() -> Result<()> { println!("Host IPv6 address: {}", host_ipv6); - // Find an available port for our IPv6-only server - let server_port = common::find_available_high_port().context("find port")?; - println!("Using port {} for IPv6-only server", server_port); - - // Start a simple HTTP server listening ONLY on IPv6 - // Using python3 since it's available and can bind to specific addresses - let mut server = tokio::process::Command::new("python3") - .args([ - "-c", - &format!( - r#" -import http.server -import socketserver -import socket - -class IPv6Server(socketserver.TCPServer): - address_family = socket.AF_INET6 - -handler = http.server.SimpleHTTPRequestHandler -with IPv6Server(('::', {}), handler) as httpd: - httpd.handle_request() # Handle one request then exit -"#, - server_port - ), - ]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .context("start IPv6 HTTP server")?; - - // Give server time to start - tokio::time::sleep(Duration::from_millis(500)).await; - - // Verify server is listening on IPv6 only - let ss_output = tokio::process::Command::new("ss") - .args(["-tlnp"]) - .output() - .await?; - let ss_stdout = String::from_utf8_lossy(&ss_output.stdout); - println!("Listening sockets:\n{}", ss_stdout); + let server = start_host_http_server().await?; + println!("HTTP server for the guest on port {}", server.port); // Start a VM let (vm_name, _, _, _) = common::unique_names("ipv6egress"); @@ -302,7 +275,7 @@ with IPv6Server(('::', {}), handler) as httpd: // Wait for VM to be healthy if let Err(e) = common::poll_health_by_pid(pid, 120).await { - server.kill().await.ok(); + server.stop().await; common::kill_process(pid).await; let _ = child.wait().await; anyhow::bail!("VM never became healthy: {}", e); @@ -310,8 +283,8 @@ with IPv6Server(('::', {}), handler) as httpd: println!("VM is healthy, testing IPv6 egress to host..."); - // Fetch from the IPv6-only server with the guest's wget (GNU wget in the guest OS). - let url = format!("http://[{}]:{}/", host_ipv6, server_port); + // Fetch from the host's server with the guest's wget (GNU wget in the guest OS). + let url = format!("http://[{}]:{}/", host_ipv6, server.port); println!("Attempting to connect to: {}", url); // The request has to reach the server above, so wget must not hand it to a proxy. @@ -337,11 +310,47 @@ with IPv6Server(('::', {}), handler) as httpd: .await; // Clean up - server.kill().await.ok(); + server.stop().await; common::kill_process(pid).await; let _ = child.wait().await; - let output = result.context("the guest could not fetch from the host's IPv6-only server")?; + let output = result.context("the guest could not fetch from the host's IPv6 server")?; + anyhow::ensure!( + output.contains("TEST_SUCCESS"), + "the fetch did not return the host server's body: {output:?}" + ); println!("✓ IPv6 egress works! Server response:\n{}", output); Ok(()) } + +/// The host's server stops when the test that started it returns before its cleanup. +#[tokio::test] +async fn host_http_server_stops_when_its_test_returns_early() -> Result<()> { + // Stand-in for a test body in which a step fails before the cleanup. + async fn fails_before_cleanup(port: &mut u16) -> Result<()> { + let server = start_host_http_server().await?; + *port = server.port; + anyhow::bail!("a step failed before the cleanup"); + } + + let mut port = 0; + fails_before_cleanup(&mut port) + .await + .expect_err("the stand-in body returns an error"); + assert_ne!(port, 0, "the stand-in body did not start its server"); + + // Nothing may still listen on the port. Binding it is the probe: a connection would + // be a request to a server that is still there. The server's task closes its listener + // when it next runs, so the bind is retried, and the deadline only bounds a failure. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match std::net::TcpListener::bind(("::", port)) { + Ok(_) => return Ok(()), + Err(error) => anyhow::ensure!( + std::time::Instant::now() < deadline, + "a server still listens on port {port} 5s after its test returned: {error}" + ), + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } +} From 556fada5f356b4c0955bb52223d5b89e072e8389 Mon Sep 17 00:00:00 2001 From: EJ Campbell Date: Sun, 4 Oct 2026 08:28:28 -0700 Subject: [PATCH 3/3] Watch the test server's own socket, not its port, in the IPv6 egress tests Three review findings on the tests of the previous commit: - host_http_server_stops_when_its_test_returns_early looked for a stopped server by binding its port. Once the server closes its socket the port is free for anyone, and nextest runs tests in parallel: another test's server that took the port would have read as a server that did not stop. LocalTestServer now hands out a weak handle to its listening socket (`listener()`), dead once the server's task has ended, and the test waits for that handle. - The same test started its server on the IPv6 wildcard address, so it failed on a host with no IPv6, where the egress test itself is skipped. The property is LocalTestServer's whatever address it binds, so the test binds IPv4 loopback. - test_ipv6_egress_to_host accepted any output that contained the server's body. It compares the fetched body exactly. Tested (x86_64), each first without its subject: make _test-root FILTER="-p fcvm --test test_rootless_ipv6 -E 'test(=host_http_server_stops_when_its_test_returns_early)'" STREAM=1 the previous test, with another listener taking the port the moment it is free: TRY 1 FAIL [ 5.050s] fcvm::test_rootless_ipv6 host_http_server_stops_when_its_test_returns_early Error: a server still listens on port 41395 5s after its test returned: Address already in use (os error 98) this test, with a LocalTestServer that keeps running when its handle is dropped: TRY 1 FAIL [ 5.030s] fcvm::test_rootless_ipv6 host_http_server_stops_when_its_test_returns_early Error: the server's listening socket is still open 5s after its test returned the test binary in a process that may open no IPv6 socket (systemd-run --user -p RestrictAddressFamilies="AF_UNIX AF_INET AF_NETLINK" --exact host_http_server_stops_when_its_test_returns_early): the previous test: panicked at tests/test_rootless_ipv6.rs:340:5, "the stand-in body did not start its server" test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 3 filtered out this test: test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out make _test-root FILTER="-p fcvm --test test_rootless_ipv6 -E 'test(=host_http_server_stops_when_its_test_returns_early) | test(=test_ipv6_egress_to_host)'" STREAM=1 PASS [ 0.047s] fcvm::test_rootless_ipv6 host_http_server_stops_when_its_test_returns_early PASS [ 16.176s] fcvm::test_rootless_ipv6 test_ipv6_egress_to_host Summary [ 16.223s] 2 tests run: 2 passed, 2 skipped make fmt leaves the tree unchanged. make clippy is clean. Not run: arm64, and a host whose kernel has no IPv6. The restriction above refuses the socket with the error such a kernel gives, EAFNOSUPPORT. --- tests/common/mod.rs | 15 +++++++++- tests/test_rootless_ipv6.rs | 55 +++++++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 16fc11050..c4e76f82a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -2125,6 +2125,8 @@ pub async fn wait_for_tcp(addr: &str, timeout_ms: u64) -> anyhow::Result<()> { pub struct LocalTestServer { shutdown_tx: tokio::sync::oneshot::Sender<()>, task: tokio::task::JoinHandle<()>, + /// The listening socket, which the server's task owns. + listener: std::sync::Weak, pub url: String, pub port: u16, } @@ -2151,7 +2153,8 @@ impl LocalTestServer { }; // Bind listener before spawning task to ensure port is available - let listener = tokio::net::TcpListener::bind(socket_addr).await?; + let listener = std::sync::Arc::new(tokio::net::TcpListener::bind(socket_addr).await?); + let listening = std::sync::Arc::downgrade(&listener); let actual_port = listener.local_addr()?.port(); let url = if is_ipv6 { format!("http://[{}]:{}/", bind_addr, actual_port) @@ -2186,6 +2189,7 @@ impl LocalTestServer { Ok(Self { shutdown_tx, task, + listener: listening, url, port: actual_port, }) @@ -2196,6 +2200,15 @@ impl LocalTestServer { Self::start(bind_addr, 0).await } + /// The server's listening socket, dead once the server has stopped and closed it. + /// + /// This is the server's own end. Its port says less: once the socket is closed any + /// process may bind the port, so a port that is taken does not show that this + /// server still runs. + pub fn listener(&self) -> std::sync::Weak { + self.listener.clone() + } + /// Stop the server. pub async fn stop(self) { let _ = self.shutdown_tx.send(()); diff --git a/tests/test_rootless_ipv6.rs b/tests/test_rootless_ipv6.rs index de618b840..eb482a500 100644 --- a/tests/test_rootless_ipv6.rs +++ b/tests/test_rootless_ipv6.rs @@ -314,43 +314,58 @@ async fn test_ipv6_egress_to_host() -> Result<()> { common::kill_process(pid).await; let _ = child.wait().await; + // exec_in_vm returns the command's standard output, which for `wget -O -` is the + // body and nothing else: wget's own line goes to standard error. let output = result.context("the guest could not fetch from the host's IPv6 server")?; anyhow::ensure!( - output.contains("TEST_SUCCESS"), + output == "TEST_SUCCESS\n", "the fetch did not return the host server's body: {output:?}" ); println!("✓ IPv6 egress works! Server response:\n{}", output); Ok(()) } -/// The host's server stops when the test that started it returns before its cleanup. +/// A host server stops when the test that started it returns before its cleanup. +/// +/// What is watched is the server's own listening socket and not its port: a port that is +/// free can be taken at once by another test's server, and a port that is taken would +/// then read as a server that did not stop. The property is `LocalTestServer`'s whatever +/// address it binds, so this binds IPv4 loopback and runs on a host with no IPv6. #[tokio::test] async fn host_http_server_stops_when_its_test_returns_early() -> Result<()> { - // Stand-in for a test body in which a step fails before the cleanup. - async fn fails_before_cleanup(port: &mut u16) -> Result<()> { - let server = start_host_http_server().await?; - *port = server.port; + type Listener = std::sync::Weak; + + // Stand-in for a test body in which a step fails before the cleanup. It leaves the + // server's listening socket and whether that was open while the body ran. + async fn fails_before_cleanup(seen: &mut Option<(Listener, bool)>) -> Result<()> { + let server = common::LocalTestServer::start_on_available_port("127.0.0.1") + .await + .context("starting the server")?; + let listener = server.listener(); + let open = listener.strong_count() > 0; + *seen = Some((listener, open)); anyhow::bail!("a step failed before the cleanup"); } - let mut port = 0; - fails_before_cleanup(&mut port) + let mut seen = None; + fails_before_cleanup(&mut seen) .await .expect_err("the stand-in body returns an error"); - assert_ne!(port, 0, "the stand-in body did not start its server"); + let (listener, open_while_running) = seen.expect("the stand-in body did not start its server"); + assert!( + open_while_running, + "the server's listening socket was not open while its test ran" + ); - // Nothing may still listen on the port. Binding it is the probe: a connection would - // be a request to a server that is still there. The server's task closes its listener - // when it next runs, so the bind is retried, and the deadline only bounds a failure. + // The server's task closes its listening socket when it next runs, so the check is + // retried, and the deadline only bounds a failure. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - match std::net::TcpListener::bind(("::", port)) { - Ok(_) => return Ok(()), - Err(error) => anyhow::ensure!( - std::time::Instant::now() < deadline, - "a server still listens on port {port} 5s after its test returned: {error}" - ), - } + while listener.strong_count() > 0 { + anyhow::ensure!( + std::time::Instant::now() < deadline, + "the server's listening socket is still open 5s after its test returned" + ); tokio::time::sleep(std::time::Duration::from_millis(20)).await; } + Ok(()) }