// 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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 netns_wrap = { "nsenter", fmt::format("--net={}", persistent_netns_path(test_network_name).string()), "--"}; auto in_netns = [&netns_wrap](std::vector argv) { std::vector 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 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 build_dns_a_query(const std::string& name, uint16_t id) { std::vector packet; auto push16 = [&](uint16_t v) { packet.push_back(static_cast(v >> 8)); packet.push_back(static_cast(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(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(&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(&addr), &addrlen); if (n < 12) { _exit(1); } uint16_t rcode = static_cast(buf[3] & 0x0f); uint16_t ancount = static_cast((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(n) && buf[pos] != 0) { pos += static_cast(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(n) && (buf[pos] & 0xc0) == 0xc0) { pos += 2; } else { while (pos < static_cast(n) && buf[pos] != 0) { pos += static_cast(buf[pos]) + 1; } pos += 1; } pos += 2 + 2 + 4; // type, class, ttl if (pos + 2 > static_cast(n)) { _exit(1); } uint16_t rdlength = static_cast((buf[pos] << 8) | buf[pos + 1]); pos += 2; if (rdlength != 4 || pos + 4 > static_cast(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(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 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); } }