// 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. // // Every network created here is named "test-..." and given an explicit // subnet in 10.169.0.0/16 (see create_test_network()'s own doc comment // below for why the explicit subnet matters) -- both deliberately distinct // from anything a real invocation would plausibly already have in use, so // a test run can't collide with pre-existing host state. #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). `subnet` is always passed explicitly // (--subnet) rather than left to allocate_ipv4_subnet()'s own auto-allocation: // each test runs under its own ScratchXdgDirs, so its own persistent.yaml // starts empty every time -- auto-allocation would always pick the very // first slot (10.168.0.0/24) with no way to see whatever real, non-test // networks already exist on the host it's running on. Confirmed by direct // testing on the real Android target device, which happens to have a real, // long-lived "extern" network already occupying exactly that subnet: every // extern-network test failed with "RTNETLINK answers: File exists" adding // its own uplink route for the identical 10.168.0.0/24 destination into the // (necessarily shared, host-root) routing table -- not a production bug, // since a real end user only ever has one persistent.yaml where allocation // correctly sees every existing entry, but a real, reproducible test-harness // gap once tests run alongside pre-existing host state. intern networks // never hit this (their routing lives entirely inside their own isolated, // per-network namespace, invisible to and unaffected by anything in host // root), which is exactly why only extern tests failed. Fixed by giving each // test network below its own fixed, explicit subnet in 10.169.0.0/16 -- // deliberately a different /16 than production's own default 10.168.0.0/16 // range, so test runs can never collide with a real network regardless of // how many the host already has. bool create_test_network(const std::string& name, NetworkKind kind, bool veth, const std::string& subnet) { 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; args.network_subnet_flag = subnet; 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, const std::string& subnet) : name_(std::move(name)) { created_ = create_test_network(name_, kind, veth, subnet); } ~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 has an assigned address (not // merely exists -- see wait_for_eth0_then()'s own doc comment below for why // that distinction matters) -- 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 -4 addr show eth0 2>/dev/null | " "grep -q 'inet ' && 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 be // usable" 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, in two // stages: // // 1. 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. Fixed by // polling for the interface's own *existence* first (`ip link show // eth0`). // // 2. That alone still wasn't enough: an interface can become visible (the // device exists, already moved/created) *before* join_one_network()'s // own later `ip addr add`/`ip link set ... up` steps for it have run. // A script that only waited for existence could still start pinging // (getting "Network unreachable", no address yet) and exit almost // immediately -- and since this sandboxed process is the pid/net // namespace's own sole occupant (`--unshare-pid`/`--unshare-net`), its // exit destroys that namespace outright, which then made join_one_ // network()'s own *remaining* steps for that same network -- or, in one // observed case, the entirely separate per-session DNS resolver's own // nsenter call (network_dns.cpp's start_dns_resolver(), also entering // this same namespace) -- fail with "No such file or directory" against // a namespace that had already collapsed underneath them. Confirmed via // a temporary production-code diagnostic that the DNS resolver's own // namespace lookup was never itself stale (proc_exists was always true // right up to its own nsenter call), narrowing the cause to the // sandboxed script's own premature exit, not a namespace-resolution bug. // Fixed by polling for an actually *assigned address* on eth0 (`ip -4 // addr show eth0 | grep -q 'inet '`) instead of mere existence -- this // only becomes true once join_one_network()'s full sequence for that // interface has completed, so the script no longer outruns its own join. 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 -4 addr show eth0 2>/dev/null | " "grep -q 'inet ' && 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("test-intern-ping-veth", NetworkKind::intern, /*veth=*/true, "10.169.0.0/24"); 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("test-extern-ping-veth", NetworkKind::extern_, /*veth=*/true, "10.169.1.0/24"); 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("test-extern-ping-tap", NetworkKind::extern_, /*veth=*/false, "10.169.2.0/24"); 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("test-extern-outside-veth", NetworkKind::extern_, /*veth=*/true, "10.169.3.0/24"); 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("test-extern-outside-tap", NetworkKind::extern_, /*veth=*/false, "10.169.4.0/24"); 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("test-intern-isolation-veth", NetworkKind::intern, /*veth=*/true, "10.169.5.0/24"); 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("test-intern-isolation-tap", NetworkKind::intern, /*veth=*/false, "10.169.6.0/24"); 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("test-intern-ping-tap", NetworkKind::intern, /*veth=*/false, "10.169.7.0/24"); 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("test-dns-ping-veth", NetworkKind::intern, /*veth=*/true, "10.169.8.0/24"); REQUIRE(network.created()); BackgroundPeer peer_a(*image, {network.name()}, std::string("test-peer-a"), 20); REQUIRE(peer_a.ready()); auto output = run_networked( *image, {network.name()}, {"sh", "-c", wait_for_hostname_then("test-peer-a", "ping -c 2 -W 2 test-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("test-dns-ping-tap", NetworkKind::intern, /*veth=*/false, "10.169.9.0/24"); REQUIRE(network.created()); BackgroundPeer peer_a(*image, {network.name()}, std::string("test-peer-a"), 20); REQUIRE(peer_a.ready()); auto output = run_networked( *image, {network.name()}, {"sh", "-c", wait_for_hostname_then("test-peer-a", "ping -c 2 -W 2 test-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); }