// Copyright (C) 2026 Viorel Munteanu // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation; either version 2 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. // [integration][root][net]: end-to-end -n/--network join scenarios, run // twice per scenario -- once with a real veth pair (the default, when the // kernel supports it), once forced onto the tap+relay fallback // (network_tap_relay.h, --with-veth=false at creation) -- since the two // mechanisms are genuinely different implementations of "get a container's // eth talking to the network's bridge", not just a policy toggle over // otherwise-identical code. IPv6 is deliberately left untested here (every // network below is created with --with-ipv6=false): it has a known // device-specific peculiarity (see docs/networking-design.md) that's out of // scope for this pass. // // Needs root (real bridges/namespaces/iptables) and a real busybox fixture // (find_busybox_fixture()) -- SKIPs cleanly on either missing. #include #include #include #include #include #include #include #include #include #include "cli_args.h" #include "commands.h" #include "config_file.h" #include "fixtures.h" #include "network_dns.h" #include "network_subnet.h" namespace { // Mirrors exactly what two separate real CLI invocations would each see: // main()'s own merge of the effective global config (g_test_app_config, // fixtures.h -- set once for this whole -t run) with persistent.yaml's own // current volumes/networks, freshly reloaded from disk. Needed because // create_network_command()/run_container() etc. always read/write // persistent_file_path() directly now (config_file.h) -- a stale in-memory // AppConfig kept across multiple dispatch_command() calls in this same test // process would miss networks a *different* call already created. AppConfig reload_config() { AppConfig config = g_test_app_config; if (auto persistent = load_persistent_config(persistent_file_path())) { config.volumes = persistent->volumes; config.networks = persistent->networks; } return config; } // Creates `name` via the real -n/--network (create) CLI path // (dispatch_command(), Mode::network) -- --with-ipv6=false always (see this // file's own top comment for why). Returns true on success. bool create_test_network(const std::string& name, NetworkKind kind, bool veth) { ParsedArgs args; args.mode = Mode::network; args.network_specs = {name}; args.network_extern_flag = (kind == NetworkKind::extern_); args.network_intern_flag = (kind == NetworkKind::intern); args.network_with_veth_flag = veth; args.network_with_ipv6_flag = false; AppConfig config = reload_config(); CapturedStdout capture; return dispatch_command(args, "/nonexistent/unused-config.yaml", config) == 0; } // Tears down a network's live host-side state and removes it from the // config, via the real --delete-network-full CLI path. Best-effort (used // from RAII cleanup, where there's nothing useful to do with a failure). void delete_test_network(const std::string& name) { ParsedArgs args; args.mode = Mode::delete_network_full; args.mode_arg = name; AppConfig config = reload_config(); CapturedStdout capture; dispatch_command(args, "/nonexistent/unused-config.yaml", config); } // The subnet `name` was actually allocated -- read back from persistent.yaml // rather than duplicating allocate_ipv4_subnet()'s own numbering logic here. std::optional test_network_subnet(const std::string& name) { auto config = reload_config(); for (const auto& network : config.networks) { if (network.name == name) { return network.subnet; } } return std::nullopt; } // RAII wrapper around create_test_network()/delete_test_network() -- so a // REQUIRE() failure partway through a test still tears down real host state // (bridge, persistent namespace, iptables rules) via normal C++ stack // unwinding, the same guarantee test_root_networking.cpp's own CHECK-not- // REQUIRE convention exists for, just via RAII instead. class TestNetwork { public: TestNetwork(std::string name, NetworkKind kind, bool veth) : name_(std::move(name)) { created_ = create_test_network(name_, kind, veth); } ~TestNetwork() { if (created_) { delete_test_network(name_); } } TestNetwork(const TestNetwork&) = delete; TestNetwork& operator=(const TestNetwork&) = delete; bool created() const { return created_; } const std::string& name() const { return name_; } private: std::string name_; bool created_ = false; }; // A container joined to a network, started in the background (forked) so a // *second* container can be started concurrently to interact with it (e.g. // ping it) while it's still running. Waits for a "READY" marker on the // child's own stdout before returning, printed only once the child's own // script has confirmed its eth actually exists -- join_networks() // (network_join.cpp) runs *concurrently with*, not before, the sandboxed // command starting (bwrap execs straight into it, with no hook point in // between), so a container that used its interface immediately could // otherwise race its own network setup; polling for it inside the // container's own script, before signaling READY, avoids that. class BackgroundPeer { public: BackgroundPeer(const std::filesystem::path& image, const std::vector& networks, const std::optional& hostname, int lifetime_seconds) { int pipe_fds[2]; if (pipe(pipe_fds) != 0) { return; } pid_ = fork(); if (pid_ == 0) { close(pipe_fds[0]); dup2(pipe_fds[1], STDOUT_FILENO); close(pipe_fds[1]); ParsedArgs args; args.mode = Mode::run; args.mode_arg = image.string(); args.network_specs = networks; args.hostname_flag = hostname; args.command = {"sh", "-c", fmt::format("for i in $(seq 1 20); do ip link show eth0 >/dev/null 2>&1 && break; " "sleep 0.5; done; echo READY; sleep {}", lifetime_seconds)}; AppConfig config = reload_config(); dispatch_command(args, "/nonexistent/unused-config.yaml", config); _exit(0); } close(pipe_fds[1]); read_fd_ = pipe_fds[0]; ready_ = wait_for_ready(); } ~BackgroundPeer() { if (pid_ > 0) { kill(pid_, SIGTERM); int status = 0; waitpid(pid_, &status, 0); } if (read_fd_ >= 0) { close(read_fd_); } } BackgroundPeer(const BackgroundPeer&) = delete; BackgroundPeer& operator=(const BackgroundPeer&) = delete; bool ready() const { return ready_; } private: // Bounded (15s, 200ms poll interval) wait for "READY" to appear on the // child's own stdout -- covers join_networks()'s own ~3s namespace- // isolation poll plus the child script's own up-to-10s eth0 poll above, // with headroom. Plain poll()/read(), matching this project's existing // direct-POSIX style elsewhere rather than /. bool wait_for_ready() { std::string buf; char chunk[256]; for (int waited_ms = 0; waited_ms < 15000; waited_ms += 200) { struct pollfd pfd { read_fd_, POLLIN, 0 }; int rc = poll(&pfd, 1, 200); if (rc > 0 && (pfd.revents & POLLIN)) { ssize_t n = read(read_fd_, chunk, sizeof(chunk)); if (n <= 0) { break; } buf.append(chunk, static_cast(n)); if (buf.find("READY") != std::string::npos) { return true; } } } return false; } pid_t pid_ = -1; int read_fd_ = -1; bool ready_ = false; }; // Prefixes `command_after_eth0` with the same "wait for eth0 to actually // exist" poll BackgroundPeer's own script above uses, wrapped between the // usual BEGIN/END-TEST-OUTPUT markers. Needed for *any* sandboxed command // that uses its network interface at all, not just BackgroundPeer's own -- // join_networks() (network_join.cpp) runs concurrently with, not before, // the sandboxed command starting (bwrap execs straight into it, no hook // point in between), so a command that used eth0 immediately could // otherwise race its own join. Confirmed by testing, not assumed: an // earlier version of this file's own tests pinged immediately, and the // container's own near-instant "ping, fail, exit" (no eth0 yet) sometimes // raced ahead of join_one_network()'s own veth-move step, which then failed // outright trying to move a veth into an already-exited container's pid // ("Invalid netns value") -- the exact documented limitation // network_join.{h,cpp}'s own CLAUDE.md entry already describes for a // very-short-lived sandboxed command. std::string wait_for_eth0_then(const std::string& command_after_eth0) { return fmt::format( "echo BEGIN-TEST-OUTPUT; for i in $(seq 1 20); do ip link show eth0 >/dev/null 2>&1 && break; " "sleep 0.5; done; {}; echo END-TEST-OUTPUT", command_after_eth0); } // Like wait_for_eth0_then(), but for a command that resolves `hostname` // before using it: retries the whole ping-by-name probe (not just an eth0 // existence check) until it succeeds or the bound is hit, then runs the // real, assertable command. A single probe covers two independent races // against the same command starting concurrently with its own join // (network_join.{h,cpp}'s own already-documented limitation): the // interface itself not being up yet, and the per-session dnsmasq resolver // (network_dns.cpp's start_dns_resolver(), started from the same // on_bwrap_pid_known callback as the join itself) not having started, or // not yet having picked up the peer's own hosts record, yet. std::string wait_for_hostname_then(const std::string& hostname, const std::string& command_after) { return fmt::format( "echo BEGIN-TEST-OUTPUT; for i in $(seq 1 20); do ping -c 1 -W 1 {0} >/dev/null 2>&1 && break; " "sleep 0.5; done; {1}; echo END-TEST-OUTPUT", hostname, command_after); } // Runs `command` inside a fresh container joined to `networks`, in the // foreground, returning its captured stdout -- the same dispatch_command() // path BackgroundPeer's own forked child uses, just synchronous. std::string run_networked(const std::filesystem::path& image, const std::vector& networks, const std::vector& command) { ParsedArgs args; args.mode = Mode::run; args.mode_arg = image.string(); args.network_specs = networks; args.command = command; AppConfig config = reload_config(); CapturedStdout capture; dispatch_command(args, "/nonexistent/unused-config.yaml", config); return capture.contents(); } } // namespace TEST_CASE("network join: two intern peers can ping each other by IP (veth)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-intern-ping-veth", NetworkKind::intern, /*veth=*/true); REQUIRE(network.created()); auto subnet = test_network_subnet(network.name()); REQUIRE(subnet.has_value()); auto peer_a_ip = ipv4_host_address(*subnet, 2); REQUIRE(peer_a_ip.has_value()); // ipv4_host_address() includes the prefix length (e.g. "10.168.0.2/24") // -- strip it for a plain ping target. std::string peer_a_addr = peer_a_ip->substr(0, peer_a_ip->find('/')); BackgroundPeer peer_a(*image, {network.name()}, std::nullopt, 20); REQUIRE(peer_a.ready()); auto output = run_networked( *image, {network.name()}, {"sh", "-c", wait_for_eth0_then(fmt::format("ping -c 2 -W 2 {}; echo RESULT=$?", peer_a_addr))}); auto lines = extract_marked_lines(output); bool found_success = false; for (const auto& line : lines) { if (line == "RESULT=0") { found_success = true; } } CHECK(found_success); } TEST_CASE("network join: two extern peers can ping each other by IP (veth)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-extern-ping-veth", NetworkKind::extern_, /*veth=*/true); REQUIRE(network.created()); auto subnet = test_network_subnet(network.name()); REQUIRE(subnet.has_value()); auto peer_a_ip = ipv4_host_address(*subnet, 2); REQUIRE(peer_a_ip.has_value()); std::string peer_a_addr = peer_a_ip->substr(0, peer_a_ip->find('/')); BackgroundPeer peer_a(*image, {network.name()}, std::nullopt, 20); REQUIRE(peer_a.ready()); auto output = run_networked( *image, {network.name()}, {"sh", "-c", wait_for_eth0_then(fmt::format("ping -c 2 -W 2 {}; echo RESULT=$?", peer_a_addr))}); auto lines = extract_marked_lines(output); bool found_success = false; for (const auto& line : lines) { if (line == "RESULT=0") { found_success = true; } } CHECK(found_success); } TEST_CASE("network join: two extern peers can ping each other by IP (tap+relay)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-extern-ping-tap", NetworkKind::extern_, /*veth=*/false); REQUIRE(network.created()); auto subnet = test_network_subnet(network.name()); REQUIRE(subnet.has_value()); auto peer_a_ip = ipv4_host_address(*subnet, 2); REQUIRE(peer_a_ip.has_value()); std::string peer_a_addr = peer_a_ip->substr(0, peer_a_ip->find('/')); BackgroundPeer peer_a(*image, {network.name()}, std::nullopt, 20); REQUIRE(peer_a.ready()); auto output = run_networked( *image, {network.name()}, {"sh", "-c", wait_for_eth0_then(fmt::format("ping -c 2 -W 2 {}; echo RESULT=$?", peer_a_addr))}); auto lines = extract_marked_lines(output); bool found_success = false; for (const auto& line : lines) { if (line == "RESULT=0") { found_success = true; } } CHECK(found_success); } TEST_CASE("network join: extern network can reach the outside (veth)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-extern-outside-veth", NetworkKind::extern_, /*veth=*/true); REQUIRE(network.created()); // 8.8.8.8 -- real internet access confirmed available in this sandbox // elsewhere this session, and network_bridge.h's own extern-uplink // verification already proved outside reachability at the host-side // level; this confirms it end-to-end through a real container join. auto output = run_networked(*image, {network.name()}, {"sh", "-c", wait_for_eth0_then("ping -c 2 -W 2 8.8.8.8; echo RESULT=$?")}); auto lines = extract_marked_lines(output); bool found_success = false; for (const auto& line : lines) { if (line == "RESULT=0") { found_success = true; } } CHECK(found_success); } TEST_CASE("network join: extern network can reach the outside (tap+relay)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-extern-outside-tap", NetworkKind::extern_, /*veth=*/false); REQUIRE(network.created()); auto output = run_networked(*image, {network.name()}, {"sh", "-c", wait_for_eth0_then("ping -c 2 -W 2 8.8.8.8; echo RESULT=$?")}); auto lines = extract_marked_lines(output); bool found_success = false; for (const auto& line : lines) { if (line == "RESULT=0") { found_success = true; } } CHECK(found_success); } TEST_CASE("network join: intern network has no route to the outside (veth)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-intern-isolation-veth", NetworkKind::intern, /*veth=*/true); REQUIRE(network.created()); // 8.8.8.8 is real, well-known and reachable outside this project's own // sandbox (already confirmed by direct testing elsewhere this session, // and by network_bridge.h's own extern-uplink verification) -- an // intern network gets no default route at all (network_join.cpp), so // this must fail, not merely time out slower than an extern join would. auto output = run_networked(*image, {network.name()}, {"sh", "-c", wait_for_eth0_then("ping -c 2 -W 2 8.8.8.8; echo RESULT=$?")}); auto lines = extract_marked_lines(output); bool found_result = false; bool found_success = false; for (const auto& line : lines) { if (line.rfind("RESULT=", 0) == 0) { found_result = true; found_success = (line == "RESULT=0"); } } CHECK(found_result); CHECK_FALSE(found_success); } TEST_CASE("network join: intern network has no route to the outside (tap+relay)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-intern-isolation-tap", NetworkKind::intern, /*veth=*/false); REQUIRE(network.created()); auto output = run_networked(*image, {network.name()}, {"sh", "-c", wait_for_eth0_then("ping -c 2 -W 2 8.8.8.8; echo RESULT=$?")}); auto lines = extract_marked_lines(output); bool found_result = false; bool found_success = false; for (const auto& line : lines) { if (line.rfind("RESULT=", 0) == 0) { found_result = true; found_success = (line == "RESULT=0"); } } CHECK(found_result); CHECK_FALSE(found_success); } TEST_CASE("network join: two intern peers can ping each other by IP (tap+relay)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-intern-ping-tap", NetworkKind::intern, /*veth=*/false); REQUIRE(network.created()); auto subnet = test_network_subnet(network.name()); REQUIRE(subnet.has_value()); auto peer_a_ip = ipv4_host_address(*subnet, 2); REQUIRE(peer_a_ip.has_value()); std::string peer_a_addr = peer_a_ip->substr(0, peer_a_ip->find('/')); BackgroundPeer peer_a(*image, {network.name()}, std::nullopt, 20); REQUIRE(peer_a.ready()); auto output = run_networked( *image, {network.name()}, {"sh", "-c", wait_for_eth0_then(fmt::format("ping -c 2 -W 2 {}; echo RESULT=$?", peer_a_addr))}); auto lines = extract_marked_lines(output); bool found_success = false; for (const auto& line : lines) { if (line == "RESULT=0") { found_success = true; } } CHECK(found_success); } TEST_CASE("network join: two peers can resolve and ping each other by hostname (veth)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } if (!is_dnsmasq_available()) { SKIP("dnsmasq not available"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-dns-ping-veth", NetworkKind::intern, /*veth=*/true); REQUIRE(network.created()); BackgroundPeer peer_a(*image, {network.name()}, std::string("peer-a"), 20); REQUIRE(peer_a.ready()); auto output = run_networked( *image, {network.name()}, {"sh", "-c", wait_for_hostname_then("peer-a", "ping -c 2 -W 2 peer-a; echo RESULT=$?")}); auto lines = extract_marked_lines(output); bool found_success = false; for (const auto& line : lines) { if (line == "RESULT=0") { found_success = true; } } CHECK(found_success); } TEST_CASE("network join: two peers can resolve and ping each other by hostname (tap+relay)", "[integration][root][net]") { if (geteuid() != 0) { SKIP("requires root"); } if (!is_dnsmasq_available()) { SKIP("dnsmasq not available"); } auto image = find_busybox_fixture(); if (!image) { SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); } ScratchXdgDirs scratch; TestNetwork network("selftest-dns-ping-tap", NetworkKind::intern, /*veth=*/false); REQUIRE(network.created()); BackgroundPeer peer_a(*image, {network.name()}, std::string("peer-a"), 20); REQUIRE(peer_a.ready()); auto output = run_networked( *image, {network.name()}, {"sh", "-c", wait_for_hostname_then("peer-a", "ping -c 2 -W 2 peer-a; echo RESULT=$?")}); auto lines = extract_marked_lines(output); bool found_success = false; for (const auto& line : lines) { if (line == "RESULT=0") { found_success = true; } } CHECK(found_success); }