From f9e68d48d938f25f32d1fdaeb2cd6fa18f1e4dea Mon Sep 17 00:00:00 2001 From: Viorel Munteanu Date: Sat, 5 Sep 2026 11:38:21 +0000 Subject: [PATCH] Add intern-network IP ping tests (veth + tap+relay variants) First of a planned series of end-to-end -n/--network join tests (tests/integration/test_network_join_scenarios.cpp, [integration][root][net]): two containers joined to the same intern network ping each other by IP, run once with a real veth pair and once forced onto the tap+relay fallback, since the two are genuinely different implementations. IPv6 is deliberately excluded pending a known device-specific peculiarity. split_lines_trimmed()/extract_marked_lines() moved from test_rootless_run.cpp into tests/support/fixtures.{h,cpp} for reuse here. wait_for_eth0_then() wraps a sandboxed command's own network-touching script in a poll for eth0 to exist first: join_networks() runs concurrently with, not before, the sandboxed command starting, so a near-instant command can otherwise exit before its own join finishes -- the exact limitation already documented in network_join.{h,cpp}'s own CLAUDE.md entry. Confirmed by testing (not assumed): without this, the container's own immediate ping-and-exit sometimes raced ahead of the veth-move step, which then failed outright ("Invalid netns value") against an already-exited pid. --- meson.build | 1 + .../test_network_join_scenarios.cpp | 336 ++++++++++++++++++ tests/integration/test_rootless_run.cpp | 39 +- tests/support/fixtures.cpp | 27 ++ tests/support/fixtures.h | 16 + 5 files changed, 383 insertions(+), 36 deletions(-) create mode 100644 tests/integration/test_network_join_scenarios.cpp diff --git a/meson.build b/meson.build index 96bf0ca..0bf0de4 100644 --- a/meson.build +++ b/meson.build @@ -32,6 +32,7 @@ if get_option('enable_tests') 'tests/integration/test_rootless_run.cpp', 'tests/integration/test_root_networking.cpp', 'tests/integration/test_session_cleanup.cpp', + 'tests/integration/test_network_join_scenarios.cpp', ] endif diff --git a/tests/integration/test_network_join_scenarios.cpp b/tests/integration/test_network_join_scenarios.cpp new file mode 100644 index 0000000..3d5a238 --- /dev/null +++ b/tests/integration/test_network_join_scenarios.cpp @@ -0,0 +1,336 @@ +// 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_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); +} + +// 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 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); +} diff --git a/tests/integration/test_rootless_run.cpp b/tests/integration/test_rootless_run.cpp index 43ed2c3..68b5afa 100644 --- a/tests/integration/test_rootless_run.cpp +++ b/tests/integration/test_rootless_run.cpp @@ -70,42 +70,9 @@ namespace { -std::vector split_lines_trimmed(const std::string& text) { - std::vector lines; - std::istringstream iss(text); - std::string line; - while (std::getline(iss, line)) { - while (!line.empty() && std::isspace(static_cast(line.back()))) { - line.pop_back(); - } - if (!line.empty()) { - lines.push_back(line); - } - } - return lines; -} - -// Captured stdout also contains slocker-lite's *own* status/log output -- -// spdlog's default sink writes to stdout, not stderr, same as the plain -// "mounted image at: ..." success line (see CLAUDE.md) -- interleaved with -// whatever the sandboxed command itself prints, since both land on the -// same fd. Confirmed directly: an early version of this test line-split -// the raw capture and expected exactly N lines, which failed with extra -// lines ("mounted image at: ...", a rootless session-cgroup permission -// warning) mixed in. Fixed by having the sandboxed command bracket its own -// real output between two unique markers and extracting only what's -// strictly between them -- robust regardless of whatever else -// slocker-lite itself prints, since in practice all of that happens -// before the sandboxed command gets to run its own first command at all. -std::vector extract_marked_lines(const std::string& text) { - auto lines = split_lines_trimmed(text); - auto begin = std::find(lines.begin(), lines.end(), "BEGIN-TEST-OUTPUT"); - auto end = std::find(lines.begin(), lines.end(), "END-TEST-OUTPUT"); - if (begin == lines.end() || end == lines.end() || end <= begin) { - return {}; - } - return std::vector(begin + 1, end); -} +// split_lines_trimmed()/extract_marked_lines() moved to tests/support/fixtures.h +// once a second file (test_network_join_scenarios.cpp) needed the exact same +// marker-extraction logic. std::string read_own_namespace_link(const char* type) { char buf[256]; diff --git a/tests/support/fixtures.cpp b/tests/support/fixtures.cpp index 95718e2..7f4f34b 100644 --- a/tests/support/fixtures.cpp +++ b/tests/support/fixtures.cpp @@ -19,6 +19,8 @@ #include #include +#include +#include #include #include #include @@ -120,3 +122,28 @@ std::string CapturedStdout::contents() { out << in.rdbuf(); return out.str(); } + +std::vector split_lines_trimmed(const std::string& text) { + std::vector lines; + std::istringstream iss(text); + std::string line; + while (std::getline(iss, line)) { + while (!line.empty() && std::isspace(static_cast(line.back()))) { + line.pop_back(); + } + if (!line.empty()) { + lines.push_back(line); + } + } + return lines; +} + +std::vector extract_marked_lines(const std::string& text) { + auto lines = split_lines_trimmed(text); + auto begin = std::find(lines.begin(), lines.end(), "BEGIN-TEST-OUTPUT"); + auto end = std::find(lines.begin(), lines.end(), "END-TEST-OUTPUT"); + if (begin == lines.end() || end == lines.end() || end <= begin) { + return {}; + } + return std::vector(begin + 1, end); +} diff --git a/tests/support/fixtures.h b/tests/support/fixtures.h index 1d342b2..80384e0 100644 --- a/tests/support/fixtures.h +++ b/tests/support/fixtures.h @@ -19,6 +19,7 @@ #include #include #include +#include #include "config_file.h" @@ -90,3 +91,18 @@ private: int saved_fd_ = -1; std::filesystem::path temp_path_; }; + +// Splits `text` into non-empty, trailing-whitespace-trimmed lines. +std::vector split_lines_trimmed(const std::string& text); + +// Extracts the lines strictly between a "BEGIN-TEST-OUTPUT"/"END-TEST-OUTPUT" +// pair (each on its own line) -- empty if either marker is missing or out of +// order. CapturedStdout also captures slocker-lite's *own* status/log output +// (spdlog's default sink writes to stdout, not stderr) interleaved with +// whatever the sandboxed command itself prints, since both land on the same +// fd -- bracketing the sandboxed command's own real output between these two +// unique markers and extracting only what's strictly between them is robust +// regardless of whatever else slocker-lite itself prints, since in practice +// all of that happens before the sandboxed command gets to run its own first +// command at all. +std::vector extract_marked_lines(const std::string& text);