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.
This commit is contained in:
2026-09-05 11:38:21 +00:00
parent 31e81c88d0
commit f9e68d48d9
5 changed files with 383 additions and 36 deletions
@@ -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<N> 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 <poll.h>
#include <unistd.h>
#include <cerrno>
#include <optional>
#include <string>
#include <vector>
#include <sys/wait.h>
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#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<std::string> 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<N> 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<std::string>& networks,
const std::optional<std::string>& 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 <chrono>/<thread>.
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<size_t>(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<std::string>& networks,
const std::vector<std::string>& 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);
}
+3 -36
View File
@@ -70,42 +70,9 @@
namespace {
std::vector<std::string> split_lines_trimmed(const std::string& text) {
std::vector<std::string> lines;
std::istringstream iss(text);
std::string line;
while (std::getline(iss, line)) {
while (!line.empty() && std::isspace(static_cast<unsigned char>(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<std::string> 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<std::string>(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];
+27
View File
@@ -19,6 +19,8 @@
#include <fcntl.h>
#include <unistd.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <fstream>
@@ -120,3 +122,28 @@ std::string CapturedStdout::contents() {
out << in.rdbuf();
return out.str();
}
std::vector<std::string> split_lines_trimmed(const std::string& text) {
std::vector<std::string> lines;
std::istringstream iss(text);
std::string line;
while (std::getline(iss, line)) {
while (!line.empty() && std::isspace(static_cast<unsigned char>(line.back()))) {
line.pop_back();
}
if (!line.empty()) {
lines.push_back(line);
}
}
return lines;
}
std::vector<std::string> 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<std::string>(begin + 1, end);
}
+16
View File
@@ -19,6 +19,7 @@
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
#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<std::string> 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<std::string> extract_marked_lines(const std::string& text);