174b6b4c29
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.
453 lines
17 KiB
C++
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);
|
|
}
|
|
}
|