Files
slocker-lite/tests/integration/test_root_networking.cpp
ceamac 174b6b4c29 Use explicit test-only subnets and test- prefixed names in the test suite
Running the full device test suite on the real Android target found a
genuine test-harness gap: every extern-network test in
test_network_join_scenarios.cpp failed with "RTNETLINK answers: File
exists" adding its own uplink route. Root cause: each test runs under its
own ScratchXdgDirs, so its own persistent.yaml starts empty every time --
allocate_ipv4_subnet()'s own auto-allocation always picks the very first
slot (10.168.0.0/24) with no way to see whatever real, non-test networks
already exist on the host. The device happens to have a real, long-lived
"extern" network already occupying exactly that subnet from prior manual
testing, and since extern's uplink adds a route back into the (necessarily
shared, host-root) routing table -- unlike intern, whose routing lives
entirely inside its own isolated per-network namespace -- every extern
test collided with it. Not a production bug: a real end user only ever has
one persistent.yaml where allocation correctly sees every existing entry.

Fixed by giving each of the 10 test networks in
test_network_join_scenarios.cpp its own fixed, explicit subnet (--subnet)
in 10.169.0.0/16 -- a different /16 than production's own default
10.168.0.0/16 range, so a test run can't collide with a real network
regardless of how many the host already has.

Also renamed every network/hostname/container-name string literal used
across the test suite (test_network_join_scenarios.cpp,
test_root_networking.cpp, test_rootless_run.cpp,
test_session_cleanup.cpp) from "selftest*" to "test-*", to further reduce
the chance of colliding with anything a real invocation might already be
using. Low-level interface device literals (slkselftest0/thselftest0/
ethselftest in test_root_networking.cpp) are left as-is -- they're
internal identifiers for a throwaway unit test, not network or container
names.

Verified: clean rebuild, meson test, and 3 consecutive
[integration][root] suite runs (61 assertions, 14 test cases) with no
failures.
2026-09-05 14:03:14 +00:00

453 lines
17 KiB
C++

// 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] tests exercising real host-side network state:
// persistent network namespaces, the tap+relay veth substitute, and the
// per-session DNS resolver. Ported from the old hand-rolled self_test.cpp
// (see git history) into proper Catch2 TEST_CASEs -- every assertion uses
// CHECK (not REQUIRE) specifically so a failure partway through still lets
// the rest of the function reach its own cleanup at the end, since these
// tests manage real OS-level resources (namespaces, bridges, tap devices,
// forked processes) that must not leak just because an earlier assertion
// failed. Simple `if`/pid guards skip dependent steps once a prerequisite
// is known to have failed, rather than a REQUIRE-triggered abort that
// would skip cleanup entirely.
#include <sched.h>
#include <signal.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <cerrno>
#include <string>
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <catch2/catch_test_macros.hpp>
#include <fmt/core.h>
#include "config_file.h"
#include "network_dns.h"
#include "network_tap_relay.h"
#include "persistent_netns.h"
#include "process.h"
#include "sandbox_process.h"
namespace {
// Bounded (1s, 20ms interval) poll for `child_pid`'s own net namespace to
// actually differ from ours -- fork() returning to the parent doesn't mean
// the child has reached its own unshare(CLONE_NEWNET) call yet, the same
// race network_join.cpp's own wait_for_isolated_net_namespace() guards
// against for a real session's namespace.
bool wait_for_net_namespace_isolated(pid_t child_pid) {
for (int elapsed_ms = 0; elapsed_ms <= 1000; elapsed_ms += 20) {
if (namespace_isolated(getpid(), child_pid, "net")) {
return true;
}
struct timespec ts {
0, 20L * 1000000L
};
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
}
}
return false;
}
} // namespace
TEST_CASE("persistent network namespace create/verify/remove", "[integration][root][net]") {
if (geteuid() != 0) {
SKIP("requires root");
}
constexpr std::string_view test_netns_name = "test-netns";
// Clean up a leftover from a previous interrupted run, if any --
// create_persistent_netns() refuses to overwrite an existing live
// namespace.
if (persistent_netns_exists(test_netns_name)) {
remove_persistent_netns(test_netns_name);
}
bool created = create_persistent_netns(test_netns_name);
CHECK(created);
if (created) {
// Checked from this (parent) process, after the child that
// actually did the unshare()/bind-mount has already exited --
// this is exactly what confirms the namespace outlives its
// creating process, the whole point of the bind-mount technique.
CHECK(persistent_netns_exists(test_netns_name));
CHECK(remove_persistent_netns(test_netns_name));
CHECK_FALSE(persistent_netns_exists(test_netns_name));
}
}
// Exercises network_tap_relay.h's create/verify/teardown cycle end to end:
// a throwaway bridge stands in for a real network's bridge, inside a
// throwaway persistent namespace (persistent_netns.h) standing in for a
// real network's own dedicated one -- create_tap_relay() always enters
// that namespace first (network_bridge.cpp's wrap_for_network() reaches a
// real network's bridge the same way, both kinds), so this test needs one
// too, even though network_bridge.h's own provisioning isn't otherwise
// exercised here (a tap device only cares that *some* bridge interface
// exists to attach to). A throwaway network namespace (kept alive by a
// child blocked in pause()) stands in for a real -r/--run session's
// isolated net namespace.
TEST_CASE("tap-relay create/attach/teardown", "[integration][root][net]") {
if (geteuid() != 0) {
SKIP("requires root");
}
const std::string test_network_name = "test-tap-relay";
const std::string test_bridge = "slkselftest0";
const std::string host_tap = "thselftest0";
const std::string container_if = "ethselftest";
if (persistent_netns_exists(test_network_name)) {
remove_persistent_netns(test_network_name);
}
bool netns_created = create_persistent_netns(test_network_name);
CHECK(netns_created);
std::vector<std::string> netns_wrap = {
"nsenter", fmt::format("--net={}", persistent_netns_path(test_network_name).string()), "--"};
auto in_netns = [&netns_wrap](std::vector<std::string> argv) {
std::vector<std::string> wrapped = netns_wrap;
wrapped.insert(wrapped.end(), argv.begin(), argv.end());
return wrapped;
};
bool bridge_ready = netns_created &&
run_process(in_netns({"ip", "link", "add", test_bridge, "type", "bridge"})).exit_code == 0 &&
run_process(in_netns({"ip", "link", "set", test_bridge, "up"})).exit_code == 0;
CHECK(bridge_ready);
// A throwaway, otherwise-empty network namespace standing in for a
// real session's own -- kept alive only by this child blocking in
// pause() until signaled, mirroring how bwrap's own sandboxed child
// keeps a real session's namespace alive for as long as it runs.
pid_t container_pid = fork();
CHECK(container_pid >= 0);
if (container_pid == 0) {
if (unshare(CLONE_NEWNET) != 0) {
_exit(1);
}
pause();
_exit(0);
}
bool isolated = container_pid > 0 && wait_for_net_namespace_isolated(container_pid);
if (container_pid > 0) {
CHECK(isolated);
}
std::optional<TapRelayHandle> relay;
if (bridge_ready && isolated) {
NetworkEntry network{test_network_name, NetworkKind::extern_, "", false, "", true};
// create_tap_relay() forks a relay child that relies on SIGTERM's
// *default* disposition to terminate cleanly once stop_tap_relay()
// (below) signals it -- network_tap_relay.cpp's own doc comment on
// the relay loop is explicit that no SIGTERM handler is installed
// there, by design. Real bug found running this under Catch2, not
// assumed: Catch2 installs its own fatal-signal handler around a
// running TEST_CASE, which the forked child inherits -- so its own
// ordinary shutdown signal gets caught by that inherited handler
// *in the child* instead of terminating it, producing a spurious
// "FAILED ... due to a fatal error condition: SIGTERM" report
// interleaved into this process's own Catch2 output (confirmed:
// the actual test result/exit code were unaffected either way --
// just confusing, misleading terminal output). Only the
// disposition *at fork time* is inherited, so resetting SIGTERM to
// SIG_DFL just around this call (and restoring right after) is
// enough; nothing about how long the relay child then keeps
// running matters.
auto previous_sigterm = signal(SIGTERM, SIG_DFL);
relay = create_tap_relay(network, test_bridge, host_tap, container_pid, container_if);
signal(SIGTERM, previous_sigterm);
CHECK(relay.has_value());
}
if (relay) {
auto host_check = run_process(in_netns({"ip", "link", "show", host_tap}));
CHECK(host_check.exit_code == 0);
CHECK(host_check.stdout_output.find("master " + test_bridge) != std::string::npos);
auto container_check = run_process({"nsenter", fmt::format("--net=/proc/{}/ns/net", container_pid), "--",
"ip", "link", "show", container_if});
CHECK(container_check.exit_code == 0);
stop_tap_relay(*relay);
CHECK(run_process(in_netns({"ip", "link", "show", host_tap})).exit_code != 0);
// The container-side device is expected to survive the relay
// stopping -- it's persistent (create_persistent_tap(),
// network_tap_relay.cpp) and lives inside the container's own
// network namespace, which stopping the relay doesn't touch. It
// only disappears once that namespace itself is destroyed, below.
CHECK(run_process({"nsenter", fmt::format("--net=/proc/{}/ns/net", container_pid), "--", "ip", "link",
"show", container_if})
.exit_code == 0);
}
if (container_pid > 0) {
kill(container_pid, SIGKILL);
int status = 0;
waitpid(container_pid, &status, 0);
}
if (bridge_ready) {
run_process(in_netns({"ip", "link", "del", test_bridge}));
}
if (netns_created) {
remove_persistent_netns(test_network_name);
}
}
namespace {
// Builds a minimal DNS query (one question, A record, class IN) for `name`,
// wire format -- just enough to exercise the resolver's own answer path,
// not a general-purpose DNS client.
std::vector<uint8_t> build_dns_a_query(const std::string& name, uint16_t id) {
std::vector<uint8_t> packet;
auto push16 = [&](uint16_t v) {
packet.push_back(static_cast<uint8_t>(v >> 8));
packet.push_back(static_cast<uint8_t>(v & 0xff));
};
push16(id);
push16(0x0100); // standard query, recursion desired
push16(1); // qdcount
push16(0);
push16(0);
push16(0); // ancount/nscount/arcount
size_t start = 0;
while (start <= name.size()) {
size_t dot = name.find('.', start);
std::string label = name.substr(start, dot == std::string::npos ? std::string::npos : dot - start);
packet.push_back(static_cast<uint8_t>(label.size()));
packet.insert(packet.end(), label.begin(), label.end());
if (dot == std::string::npos) {
break;
}
start = dot + 1;
}
packet.push_back(0);
push16(1); // qtype A
push16(1); // qclass IN
return packet;
}
// Sends build_dns_a_query(name) to 127.0.0.1:53 from *inside* `ns_pid`'s own
// network namespace (a plain setns() here, same technique
// network_tap_relay.cpp's own enter_namespace() uses, done in a throwaway
// forked child so this process's own namespace is untouched either way) and
// parses the first answer's 4-byte A-record rdata back out. Returns false on
// any failure (including NXDOMAIN/no answer) -- out_ip is only meaningful
// when this returns true.
bool query_dns_a_record(pid_t ns_pid, const std::string& name, std::string& out_ip) {
int report_pipe[2];
if (pipe(report_pipe) != 0) {
return false;
}
pid_t pid = fork();
if (pid < 0) {
close(report_pipe[0]);
close(report_pipe[1]);
return false;
}
if (pid == 0) {
close(report_pipe[0]);
int ns_fd = open(fmt::format("/proc/{}/ns/net", ns_pid).c_str(), O_RDONLY);
if (ns_fd < 0 || setns(ns_fd, CLONE_NEWNET) != 0) {
_exit(1);
}
close(ns_fd);
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) {
_exit(1);
}
struct timeval tv {
2, 0
};
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
struct sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_port = htons(53);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
auto query = build_dns_a_query(name, 0x1234);
if (sendto(sock, query.data(), query.size(), 0, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) <
0) {
_exit(1);
}
uint8_t buf[512];
socklen_t addrlen = sizeof(addr);
ssize_t n = recvfrom(sock, buf, sizeof(buf), 0, reinterpret_cast<struct sockaddr*>(&addr), &addrlen);
if (n < 12) {
_exit(1);
}
uint16_t rcode = static_cast<uint16_t>(buf[3] & 0x0f);
uint16_t ancount = static_cast<uint16_t>((buf[6] << 8) | buf[7]);
if (ancount == 0) {
// Exit code carries the RCODE (offset by 10 to stay clear of
// the other exit codes above) -- e.g. 13 = NXDOMAIN,
// 15 = REFUSED (the latter is exactly what caught this
// project's own dnsmasq privilege-drop bug during development,
// see start_dns_resolver()'s own --user=root/--group=root
// comment).
_exit(10 + rcode);
}
// Skip the 12-byte header, then the question (the exact same
// qname this process just sent, plus 4 bytes qtype/qclass).
size_t pos = 12;
while (pos < static_cast<size_t>(n) && buf[pos] != 0) {
pos += static_cast<size_t>(buf[pos]) + 1;
}
pos += 1 + 4;
// The answer's own name field: a compression pointer (top two
// bits set) is 2 bytes; a literal label sequence is read the same
// way as the question's own qname above.
if (pos < static_cast<size_t>(n) && (buf[pos] & 0xc0) == 0xc0) {
pos += 2;
} else {
while (pos < static_cast<size_t>(n) && buf[pos] != 0) {
pos += static_cast<size_t>(buf[pos]) + 1;
}
pos += 1;
}
pos += 2 + 2 + 4; // type, class, ttl
if (pos + 2 > static_cast<size_t>(n)) {
_exit(1);
}
uint16_t rdlength = static_cast<uint16_t>((buf[pos] << 8) | buf[pos + 1]);
pos += 2;
if (rdlength != 4 || pos + 4 > static_cast<size_t>(n)) {
_exit(1);
}
char ip_str[32];
std::snprintf(ip_str, sizeof(ip_str), "%u.%u.%u.%u", buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]);
ssize_t unused = write(report_pipe[1], ip_str, std::strlen(ip_str));
(void)unused;
_exit(0);
}
close(report_pipe[1]);
char buf[64] = {0};
ssize_t n = read(report_pipe[0], buf, sizeof(buf) - 1);
close(report_pipe[0]);
int status = 0;
waitpid(pid, &status, 0);
if (n <= 0) {
return false;
}
out_ip.assign(buf, static_cast<size_t>(n));
return true;
}
} // namespace
// Exercises network_dns.h's create/answer/teardown cycle end to end: a
// throwaway network namespace (same fork+unshare(CLONE_NEWNET)+pause()
// technique the tap-relay test above uses) stands in for a real -r/--run
// session's own isolated namespace. A single dns-hosts record is written
// by hand (record_dns_host()) for a fabricated `intern` network,
// start_dns_resolver() is pointed at it, and query_dns_a_record() above
// confirms the resolver actually answers with the recorded address -- real
// UDP wire format, not just "the process started". Skipped if dnsmasq
// isn't installed, same "best-effort dependency" policy the feature itself
// has.
TEST_CASE("dns-resolver create/answer/teardown", "[integration][root][net]") {
if (geteuid() != 0) {
SKIP("requires root");
}
if (!is_dnsmasq_available()) {
SKIP("dnsmasq not found in PATH");
}
const std::string test_network_name = "test-dns";
const std::string test_hostname = "test-peer";
const std::string test_ip = "10.99.99.2";
pid_t container_pid = fork();
CHECK(container_pid >= 0);
if (container_pid == 0) {
if (unshare(CLONE_NEWNET) != 0) {
_exit(1);
}
// Not automatically up in a fresh network namespace -- the
// resolver needs to bind 127.0.0.1.
run_process({"ip", "link", "set", "lo", "up"});
pause();
_exit(0);
}
bool isolated = container_pid > 0 && wait_for_net_namespace_isolated(container_pid);
if (container_pid > 0) {
CHECK(isolated);
}
std::error_code ec;
std::filesystem::remove_all(dns_hosts_dir(test_network_name), ec);
if (isolated) {
record_dns_host(test_network_name, "test-peer-container", 1, test_ip, test_hostname);
}
std::optional<DnsResolverHandle> resolver;
if (isolated) {
NetworkEntry test_network{test_network_name, NetworkKind::intern, "", false, "", true};
JoinedNetwork joined_network{test_network, test_ip, std::nullopt};
resolver = start_dns_resolver("test-dns-session", container_pid, container_pid, {joined_network});
CHECK(resolver.has_value());
}
if (resolver) {
std::string answered_ip;
bool answered = query_dns_a_record(container_pid, test_hostname, answered_ip);
CHECK(answered);
if (answered) {
CHECK(answered_ip == test_ip);
}
stop_dns_resolver(*resolver);
}
remove_dns_resolver_record("test-dns-session", container_pid);
remove_dns_host_record(test_network_name, "test-peer-container", 1);
std::filesystem::remove_all(dns_hosts_dir(test_network_name), ec);
if (container_pid > 0) {
kill(container_pid, SIGKILL);
int status = 0;
waitpid(container_pid, &status, 0);
}
}