Add per-session DNS resolution via dnsmasq
Containers on a shared -n <network> can now resolve each other by the name given via --hostname on any network they share, plus host.containers.internal (podman's convention) resolving to the first extern network's own gateway, if any. One dnsmasq instance per session, not per network: with one instance per network instead, a container joined to two networks would list two nameserver lines in resolv.conf, and standard stub resolvers don't fall through to the next nameserver on NXDOMAIN, only on timeout -- a name that exists only on the second network would silently fail to resolve. Running one instance per session, entered into the container's own network namespace and bound to 127.0.0.1:53, configured (via dnsmasq's own repeatable --hostsdir) to watch every network that specific container joined, avoids the problem entirely. Three real bugs found via direct testing while building this, not assumed: - dnsmasq only writes --pid-file while actually daemonizing; -d/--no-daemon suppresses it, so the startup-confirmation poll needs to read the real daemon pid back from the file rather than assume the forked/exec'd pid is it. - dnsmasq drops root privileges to an unprivileged user by default, which then couldn't read $XDG_STATE_HOME (under /root, mode 0700) at all -- fixed with an explicit --user=root --group=root (tracked as a security follow-up in TODO.md: run it as a low-privilege user instead and relocate the files it needs). - an AAAA query for a name with only an A record came back REFUSED (breaking any getaddrinfo()-based tool, e.g. ping, that queries both types together) unless --filter-AAAA is given; host.containers.internal additionally needed to be served via a plain --addn-hosts file rather than dnsmasq's own --address=/name/ip option, which stayed REFUSED for AAAA even with --filter-AAAA. Best-effort throughout: gated on dnsmasq actually being found in PATH, with a new --no-dns opt-out. Verified end-to-end both on this dev machine and on the real Android target device: two containers on a shared network resolve each other (including self-resolution) and can ping by name; a container joined to both an intern and an extern network resolves both its intern peer and host.containers.internal simultaneously (the specific scenario the per-network-instance design would have broken). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
+2
-1
@@ -23,7 +23,8 @@ slocker_lite = executable('slocker-lite',
|
||||
'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp',
|
||||
'src/sandbox_process.cpp', 'src/session_cgroup.cpp', 'src/kill_session.cpp',
|
||||
'src/network_subnet.cpp', 'src/persistent_netns.cpp', 'src/network_bridge.cpp',
|
||||
'src/network_join.cpp', 'src/port_forward.cpp', 'src/network_tap_relay.cpp'],
|
||||
'src/network_join.cpp', 'src/port_forward.cpp', 'src/network_tap_relay.cpp',
|
||||
'src/network_dns.cpp'],
|
||||
include_directories : include_directories('.'),
|
||||
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
|
||||
install : true)
|
||||
|
||||
+14
-3
@@ -32,6 +32,7 @@
|
||||
#include <fmt/core.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include "network_dns.h"
|
||||
#include "pid_file.h"
|
||||
#include "process.h"
|
||||
#include "session_cgroup.h"
|
||||
@@ -226,7 +227,8 @@ std::vector<std::string> build_bwrap_args(const std::string& root,
|
||||
const std::vector<ResolvedVolumeMount>& volumes,
|
||||
std::optional<ResolvedUser> user,
|
||||
const std::optional<std::string>& hostname,
|
||||
const NamespaceConfig& namespace_config) {
|
||||
const NamespaceConfig& namespace_config,
|
||||
bool inject_dns_resolv_conf) {
|
||||
// --new-session detaches from the controlling terminal, which breaks job
|
||||
// control for an interactive foreground shell ("can't access tty"). Re-enable
|
||||
// once background/daemonized runs are implemented, where that's the point.
|
||||
@@ -299,6 +301,15 @@ std::vector<std::string> build_bwrap_args(const std::string& root,
|
||||
args.insert(args.end(), {"--bind", volume.host_directory, volume.container_path});
|
||||
}
|
||||
|
||||
if (inject_dns_resolv_conf) {
|
||||
if (auto resolv_conf = ensure_generated_resolv_conf()) {
|
||||
args.insert(args.end(), {"--ro-bind", resolv_conf->string(), "/etc/resolv.conf"});
|
||||
} else {
|
||||
spdlog::warn("failed to generate /etc/resolv.conf for the per-session DNS resolver; "
|
||||
"leaving the image's own /etc/resolv.conf alone");
|
||||
}
|
||||
}
|
||||
|
||||
bool bound_priv_drop_helper = false;
|
||||
if (user) {
|
||||
auto helper_path = find_priv_drop_helper();
|
||||
@@ -356,7 +367,7 @@ int run_bwrap(const std::string& root, const std::vector<std::string>& command,
|
||||
const std::vector<ResolvedVolumeMount>& volumes, std::optional<ResolvedUser> user,
|
||||
const std::optional<std::string>& hostname, const std::string& container_name,
|
||||
const std::vector<std::pair<std::string, std::string>>& extra_env,
|
||||
const NamespaceConfig& namespace_config,
|
||||
const NamespaceConfig& namespace_config, bool inject_dns_resolv_conf,
|
||||
const std::function<void(pid_t)>& on_bwrap_pid_known) {
|
||||
if (user && !find_priv_drop_helper()) {
|
||||
spdlog::error("could not find the {} helper next to this binary; --user/--group requires it",
|
||||
@@ -364,7 +375,7 @@ int run_bwrap(const std::string& root, const std::vector<std::string>& command,
|
||||
return -1;
|
||||
}
|
||||
|
||||
auto bwrap_args = build_bwrap_args(root, command, volumes, user, hostname, namespace_config);
|
||||
auto bwrap_args = build_bwrap_args(root, command, volumes, user, hostname, namespace_config, inject_dns_resolv_conf);
|
||||
auto argv = wrap_for_root_namespace(root, use_nsenter, bwrap_args);
|
||||
if (!argv) {
|
||||
return -1;
|
||||
|
||||
+11
-3
@@ -101,13 +101,21 @@ struct NamespaceConfig {
|
||||
// running as root (see detect_bwrap_unshare_args()). If `hostname` is set and the
|
||||
// kernel supports --unshare-uts (bwrap refuses --hostname without it), passes it as
|
||||
// bwrap's own --hostname; otherwise logs a warning and leaves the sandbox's
|
||||
// hostname alone.
|
||||
// hostname alone. If `inject_dns_resolv_conf` is true, bind-mounts a small,
|
||||
// idempotently-generated /etc/resolv.conf (network_dns.h's
|
||||
// ensure_generated_resolv_conf() -- always just "nameserver 127.0.0.1\n",
|
||||
// since the per-session DNS resolver network_dns.h spawns always binds
|
||||
// there) read-only over the image's own -- the caller (run_container(),
|
||||
// commands.cpp) decides this once, from whether any network was actually
|
||||
// requested and dnsmasq is available, keeping that policy decision out of
|
||||
// this file.
|
||||
std::vector<std::string> build_bwrap_args(const std::string& root,
|
||||
const std::vector<std::string>& command,
|
||||
const std::vector<ResolvedVolumeMount>& volumes,
|
||||
std::optional<ResolvedUser> user,
|
||||
const std::optional<std::string>& hostname,
|
||||
const NamespaceConfig& namespace_config);
|
||||
const NamespaceConfig& namespace_config,
|
||||
bool inject_dns_resolv_conf);
|
||||
|
||||
// Runs bwrap against `root` (the merged mount path from mount_layer()) in the
|
||||
// foreground and waits for it to exit. If `use_nsenter` is true, first locates the
|
||||
@@ -136,5 +144,5 @@ int run_bwrap(const std::string& root, const std::vector<std::string>& command,
|
||||
const std::vector<ResolvedVolumeMount>& volumes, std::optional<ResolvedUser> user,
|
||||
const std::optional<std::string>& hostname, const std::string& container_name,
|
||||
const std::vector<std::pair<std::string, std::string>>& extra_env,
|
||||
const NamespaceConfig& namespace_config,
|
||||
const NamespaceConfig& namespace_config, bool inject_dns_resolv_conf,
|
||||
const std::function<void(pid_t)>& on_bwrap_pid_known = nullptr);
|
||||
|
||||
+16
-2
@@ -64,9 +64,10 @@ constexpr int list_networks = 274;
|
||||
constexpr int delete_network = 275;
|
||||
constexpr int network_no_veth = 276;
|
||||
constexpr int delete_network_full = 277;
|
||||
constexpr int no_dns = 278;
|
||||
} // namespace options
|
||||
|
||||
constexpr std::array<struct option, 38> long_options = {{
|
||||
constexpr std::array<struct option, 39> long_options = {{
|
||||
{"help", no_argument, nullptr, 'h'},
|
||||
{"version", no_argument, nullptr, 'V'},
|
||||
{"test", no_argument, nullptr, 't'},
|
||||
@@ -104,6 +105,7 @@ constexpr std::array<struct option, 38> long_options = {{
|
||||
{"delete-network", required_argument, nullptr, options::delete_network},
|
||||
{"delete-network-full", required_argument, nullptr, options::delete_network_full},
|
||||
{"port-forward", required_argument, nullptr, 'p'},
|
||||
{"no-dns", no_argument, nullptr, options::no_dns},
|
||||
{nullptr, 0, nullptr, 0},
|
||||
}};
|
||||
|
||||
@@ -112,7 +114,7 @@ void print_usage(const char* prog) {
|
||||
"usage: {0} -m|--mount <image.tar>\n"
|
||||
" {0} -r|--run <image.tar> [-v <name-or-dir> <container-path>]...\n"
|
||||
" [-n <network>]... [-p [<network>:]<host-port>:<container-port>]...\n"
|
||||
" [-- <command> [args...]]\n"
|
||||
" [--no-dns] [-- <command> [args...]]\n"
|
||||
" {0} -u|--umount <layer-id>\n"
|
||||
" {0} -c|--cleanup <layer-id>\n"
|
||||
" {0} -l|--list-images <directory>\n"
|
||||
@@ -254,6 +256,15 @@ void print_usage(const char* prog) {
|
||||
" defaults to the container's sole --extern\n"
|
||||
" network (an error if it joined more than one);\n"
|
||||
" may be repeated\n"
|
||||
" --no-dns with --run, don't start the per-session DNS\n"
|
||||
" resolver even if dnsmasq is available -- by\n"
|
||||
" default, joining any network (-n) starts one,\n"
|
||||
" resolving other containers' --hostname on any\n"
|
||||
" network shared with this one, plus\n"
|
||||
" host.containers.internal (the first --extern\n"
|
||||
" network joined, if any). No effect if dnsmasq\n"
|
||||
" isn't installed -- the resolver is skipped\n"
|
||||
" either way, with a warning\n"
|
||||
" --list-processes list running --run sessions found by their pid\n"
|
||||
" files under $XDG_STATE_HOME/slocker-lite/run/,\n"
|
||||
" with their pid, container name, and status\n"
|
||||
@@ -454,6 +465,9 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
|
||||
// standalone use at all (checked post-loop).
|
||||
out.port_forward_specs.push_back(optarg);
|
||||
break;
|
||||
case options::no_dns:
|
||||
out.no_dns_flag = true;
|
||||
break;
|
||||
case options::no_nsenter:
|
||||
out.disable_nsenter = true;
|
||||
break;
|
||||
|
||||
@@ -82,6 +82,10 @@ struct ParsedArgs {
|
||||
// resolving which network a spec refers to needs runtime join state that
|
||||
// doesn't exist yet at parse time.
|
||||
std::vector<std::string> port_forward_specs;
|
||||
// Opts out of the per-session DNS resolver (network_dns.h) even when
|
||||
// dnsmasq is available -- same "opt out of an otherwise-on-by-default
|
||||
// networking convenience" precedent as --no-veth/--no-ipv6.
|
||||
bool no_dns_flag = false;
|
||||
// Trailing argv (after getopt_long stops), populated only for
|
||||
// Mode::run/Mode::exec -- the command to run, or to run under -x/--exec.
|
||||
std::vector<std::string> command;
|
||||
|
||||
+51
-3
@@ -39,6 +39,7 @@
|
||||
#include "exec_session.h"
|
||||
#include "kill_session.h"
|
||||
#include "network_bridge.h"
|
||||
#include "network_dns.h"
|
||||
#include "network_join.h"
|
||||
#include "network_subnet.h"
|
||||
#include "network_tap_relay.h"
|
||||
@@ -46,6 +47,7 @@
|
||||
#include "pid_file.h"
|
||||
#include "port_forward.h"
|
||||
#include "process.h"
|
||||
#include "sandbox_process.h"
|
||||
#include "self_test.h"
|
||||
#include "user_spec.h"
|
||||
#include "volume_mount.h"
|
||||
@@ -344,6 +346,9 @@ int clean_processes_command() {
|
||||
for (const auto& removed : clean_stale_tap_relays()) {
|
||||
fmt::print("removed stale tap-relay processes for '{}'\n", removed);
|
||||
}
|
||||
for (const auto& removed : clean_stale_dns_resolvers()) {
|
||||
fmt::print("removed stale DNS resolver state for '{}'\n", removed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -579,7 +584,8 @@ int run_container(const std::filesystem::path& image_tar,
|
||||
const std::vector<std::pair<std::string, std::string>>& volume_specs,
|
||||
const std::vector<EnvSpec>& env_specs, bool daemonize_flag,
|
||||
const std::vector<std::string>& network_specs,
|
||||
const std::vector<std::string>& port_forward_specs, const AppConfig& app_config) {
|
||||
const std::vector<std::string>& port_forward_specs, bool no_dns_flag,
|
||||
const AppConfig& app_config) {
|
||||
// Only depends on image_tar, so this can run before mount_image() -- moved
|
||||
// up here (rather than right before the run_bwrap() call, as before) so
|
||||
// daemonize() below can use the real container name for the log file from
|
||||
@@ -722,10 +728,30 @@ int run_container(const std::filesystem::path& image_tar,
|
||||
// independent process with no such automatic cleanup.
|
||||
std::vector<TapRelayHandle> active_relays;
|
||||
|
||||
// Set inside on_bwrap_pid_known (below) if any network was joined and
|
||||
// DNS resolution is active for this session (network_dns.h) -- read
|
||||
// again after run_bwrap() returns to stop it. dns_host_networks
|
||||
// parallels this: the names of the networks this session actually wrote
|
||||
// its own dns-hosts record into (only when --hostname was given), so
|
||||
// those records can be removed the same way.
|
||||
std::optional<DnsResolverHandle> dns_resolver;
|
||||
std::vector<std::string> dns_host_networks;
|
||||
|
||||
// Set inside on_bwrap_pid_known below, read again after run_bwrap()
|
||||
// returns to remove this session's own port-forward state record.
|
||||
pid_t bwrap_pid = -1;
|
||||
|
||||
// Resolved once, up front, the same "policy decision kept out of
|
||||
// bwrap.cpp" pattern namespace_config already uses -- whether a network
|
||||
// was actually requested and dnsmasq is available decides whether the
|
||||
// sandbox's own /etc/resolv.conf gets replaced with one pointing at the
|
||||
// per-session resolver this same run will start below.
|
||||
bool inject_dns_resolv_conf = !network_specs.empty() && !no_dns_flag && is_dnsmasq_available();
|
||||
if (!network_specs.empty() && !no_dns_flag && !inject_dns_resolv_conf) {
|
||||
spdlog::warn("dnsmasq not found in PATH; DNS resolution for other containers' --hostname and "
|
||||
"host.containers.internal will not be available for this session");
|
||||
}
|
||||
|
||||
std::function<void(pid_t)> on_bwrap_pid_known;
|
||||
if (daemonize_flag || !network_specs.empty() || !parsed_port_forwards.empty()) {
|
||||
on_bwrap_pid_known = [&](pid_t pid) {
|
||||
@@ -761,6 +787,17 @@ int run_container(const std::filesystem::path& image_tar,
|
||||
// forever if this process crashes before its own
|
||||
// stop_tap_relay() calls below ever run.
|
||||
record_tap_relays(container_name, pid, active_relays);
|
||||
if (!joined.empty() && inject_dns_resolv_conf) {
|
||||
if (hostname) {
|
||||
for (const auto& joined_network : joined) {
|
||||
record_dns_host(joined_network.network.name, container_name, pid,
|
||||
joined_network.container_ip, *hostname);
|
||||
dns_host_networks.push_back(joined_network.network.name);
|
||||
}
|
||||
}
|
||||
pid_t ns_pid = resolve_namespace_pid(pid);
|
||||
dns_resolver = start_dns_resolver(container_name, pid, ns_pid, joined);
|
||||
}
|
||||
if (daemonize_flag) {
|
||||
report_daemon_started(container_name, pid);
|
||||
}
|
||||
@@ -770,7 +807,8 @@ int run_container(const std::filesystem::path& image_tar,
|
||||
int exit_code = -1;
|
||||
if (ok) {
|
||||
exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname,
|
||||
container_name, *resolved_env, namespace_config, on_bwrap_pid_known);
|
||||
container_name, *resolved_env, namespace_config, inject_dns_resolv_conf,
|
||||
on_bwrap_pid_known);
|
||||
if (exit_code < 0) {
|
||||
spdlog::error("failed to run bwrap");
|
||||
}
|
||||
@@ -790,6 +828,16 @@ int run_container(const std::filesystem::path& image_tar,
|
||||
remove_tap_relay_record(container_name, bwrap_pid);
|
||||
}
|
||||
|
||||
if (dns_resolver) {
|
||||
stop_dns_resolver(*dns_resolver);
|
||||
}
|
||||
if (bwrap_pid > 0) {
|
||||
remove_dns_resolver_record(container_name, bwrap_pid);
|
||||
for (const auto& network_name : dns_host_networks) {
|
||||
remove_dns_host_record(network_name, container_name, bwrap_pid);
|
||||
}
|
||||
}
|
||||
|
||||
if (!unmount_layer(mounted->top_layer_id)) {
|
||||
spdlog::error("failed to unmount layer {}", mounted->top_layer_id);
|
||||
}
|
||||
@@ -867,7 +915,7 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config
|
||||
}
|
||||
return run_container(args.mode_arg, args.command, use_nsenter, args.user_flag, args.group_flag,
|
||||
args.hostname_flag, args.volume_specs, args.env_specs, args.daemonize_flag,
|
||||
args.network_specs, args.port_forward_specs, config);
|
||||
args.network_specs, args.port_forward_specs, args.no_dns_flag, config);
|
||||
}
|
||||
}
|
||||
return 1; // unreachable
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
// 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.
|
||||
|
||||
#include "network_dns.h"
|
||||
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <system_error>
|
||||
|
||||
#include <fmt/core.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include "config_file.h"
|
||||
#include "network_subnet.h"
|
||||
#include "pid_file.h"
|
||||
#include "process.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// "10.168.0.1/24" -> "10.168.0.1" -- dnsmasq's --address=/name/ip wants a
|
||||
// bare address, not a CIDR. Same trivial extraction network_bridge.cpp's own
|
||||
// (`.cpp`-local) strip_prefix() already does for the uplink's addresses; not
|
||||
// worth exporting a shared helper for one line.
|
||||
std::string strip_prefix(const std::string& cidr) { return cidr.substr(0, cidr.find('/')); }
|
||||
|
||||
std::filesystem::path dns_host_record_path(const std::string& network_name, std::string_view container_name,
|
||||
pid_t pid) {
|
||||
return dns_hosts_dir(network_name) / fmt::format("{}-{}", sanitize_for_filename(container_name), pid);
|
||||
}
|
||||
|
||||
bool session_still_running(const std::vector<SessionInfo>& sessions, const std::string& filename) {
|
||||
return std::any_of(sessions.begin(), sessions.end(), [&](const SessionInfo& session) {
|
||||
return session.running && session.path.filename().string() == filename;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<std::filesystem::path> ensure_generated_resolv_conf() {
|
||||
auto path = xdg_state_dir() / "dns-resolv.conf";
|
||||
if (std::filesystem::exists(path)) {
|
||||
return path;
|
||||
}
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
if (ec) {
|
||||
spdlog::warn("failed to create directory {}: {}", path.parent_path().string(), ec.message());
|
||||
return std::nullopt;
|
||||
}
|
||||
std::ofstream out(path);
|
||||
if (!out) {
|
||||
spdlog::warn("failed to create {}", path.string());
|
||||
return std::nullopt;
|
||||
}
|
||||
out << "nameserver 127.0.0.1\n";
|
||||
return path;
|
||||
}
|
||||
|
||||
bool is_dnsmasq_available() { return find_in_path("dnsmasq").has_value(); }
|
||||
|
||||
std::filesystem::path dns_hosts_dir(const std::string& network_name) {
|
||||
return xdg_state_dir() / "dns-hosts" / sanitize_for_filename(network_name);
|
||||
}
|
||||
|
||||
void record_dns_host(const std::string& network_name, std::string_view container_name, pid_t pid,
|
||||
const std::string& ip, const std::string& hostname) {
|
||||
auto path = dns_host_record_path(network_name, container_name, pid);
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
if (ec) {
|
||||
spdlog::warn("failed to create directory {}: {}", path.parent_path().string(), ec.message());
|
||||
return;
|
||||
}
|
||||
std::ofstream out(path);
|
||||
if (!out) {
|
||||
spdlog::warn("failed to create {}", path.string());
|
||||
return;
|
||||
}
|
||||
out << ip << ' ' << hostname << '\n';
|
||||
}
|
||||
|
||||
void remove_dns_host_record(const std::string& network_name, std::string_view container_name, pid_t pid) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(dns_host_record_path(network_name, container_name, pid), ec);
|
||||
}
|
||||
|
||||
std::filesystem::path dns_resolver_state_path(std::string_view container_name, pid_t pid) {
|
||||
return xdg_state_dir() / "dns-resolvers" / fmt::format("{}-{}", sanitize_for_filename(container_name), pid);
|
||||
}
|
||||
|
||||
std::optional<DnsResolverHandle> start_dns_resolver(std::string_view container_name, pid_t pid, pid_t ns_pid,
|
||||
const std::vector<JoinedNetwork>& joined) {
|
||||
if (joined.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto dnsmasq_path = find_in_path("dnsmasq");
|
||||
auto nsenter_path = find_in_path("nsenter");
|
||||
if (!dnsmasq_path || !nsenter_path) {
|
||||
spdlog::warn("dnsmasq or nsenter not found in PATH -- DNS resolution for '{}' will not be available",
|
||||
container_name);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto state_path = dns_resolver_state_path(container_name, pid);
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(state_path.parent_path(), ec);
|
||||
if (ec) {
|
||||
spdlog::warn("failed to create directory {}: {}", state_path.parent_path().string(), ec.message());
|
||||
return std::nullopt;
|
||||
}
|
||||
// Remove any stale leftover first so the startup poll below can't mistake
|
||||
// it for a fresh write.
|
||||
std::filesystem::remove(state_path, ec);
|
||||
|
||||
std::vector<std::string> argv = {
|
||||
nsenter_path->string(),
|
||||
fmt::format("--net=/proc/{}/ns/net", ns_pid),
|
||||
"--",
|
||||
dnsmasq_path->string(),
|
||||
fmt::format("--pid-file={}", state_path.string()),
|
||||
"--port=53",
|
||||
"--listen-address=127.0.0.1",
|
||||
"--no-resolv",
|
||||
"--no-hosts",
|
||||
"--local-ttl=5",
|
||||
// dnsmasq drops root privileges to an unprivileged user by default --
|
||||
// **real bug found by testing, not assumed**: it then couldn't read
|
||||
// any of the --hostsdir directories below at all ("Permission
|
||||
// denied"), since this project's own state lives under
|
||||
// $XDG_STATE_HOME (typically /root/.local/state/... on the real
|
||||
// target device), and a non-root user can't even traverse into
|
||||
// /root. Kept as root explicitly -- everything else in this
|
||||
// project's networking already runs as root by the same logic
|
||||
// (bwrap.cpp never requests --unshare-user when already root, for
|
||||
// the same "no privilege drop needed/wanted here" reasoning).
|
||||
"--user=root",
|
||||
"--group=root",
|
||||
// This resolver only answers A records (JoinedNetwork::container_ip
|
||||
// is IPv4-only) -- without this, an AAAA query for a name we *do*
|
||||
// know (e.g. a peer container's own --hostname) gets REFUSED rather
|
||||
// than a clean "no data" answer, since dnsmasq has no upstream to
|
||||
// recurse to and no AAAA record of its own to offer. **Real bug
|
||||
// found by testing, not assumed**: this broke `ping <name>` even
|
||||
// though the name's own A record resolved correctly moments earlier
|
||||
// via nslookup -- busybox ping (like most getaddrinfo()-based tools)
|
||||
// queries both A and AAAA together and treats REFUSED on either as a
|
||||
// hard failure for the whole lookup, not just "no IPv6 available".
|
||||
"--filter-AAAA",
|
||||
};
|
||||
|
||||
for (const auto& joined_network : joined) {
|
||||
auto dir = dns_hosts_dir(joined_network.network.name);
|
||||
std::filesystem::create_directories(dir, ec); // dnsmasq needs --hostsdir targets to already exist
|
||||
argv.push_back(fmt::format("--hostsdir={}", dir.string()));
|
||||
}
|
||||
|
||||
// host.containers.internal (podman's own convention): the first `extern`
|
||||
// network's own gateway, in `-n` order, via a plain hosts-file
|
||||
// (--addn-hosts) rather than dnsmasq's own --address=/name/ip -- see
|
||||
// this function's own header doc comment for why. Omitted entirely for
|
||||
// an intern-only session, matching this project's existing "no route
|
||||
// out" intent there.
|
||||
std::optional<std::filesystem::path> internal_hosts_file;
|
||||
auto first_extern = std::find_if(joined.begin(), joined.end(),
|
||||
[](const JoinedNetwork& n) { return n.network.kind == NetworkKind::extern_; });
|
||||
if (first_extern != joined.end()) {
|
||||
if (auto gateway = ipv4_gateway_address(first_extern->network.subnet)) {
|
||||
auto path = xdg_state_dir() / "dns-internal-hosts" / fmt::format("{}-{}", sanitize_for_filename(container_name), pid);
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
std::ofstream out(path);
|
||||
if (out) {
|
||||
out << strip_prefix(*gateway) << " host.containers.internal\n";
|
||||
internal_hosts_file = path;
|
||||
argv.push_back(fmt::format("--addn-hosts={}", path.string()));
|
||||
} else {
|
||||
spdlog::warn("failed to create {}; host.containers.internal will not be available for '{}'",
|
||||
path.string(), container_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pid_t resolver_pid = fork();
|
||||
if (resolver_pid < 0) {
|
||||
spdlog::error("failed to fork DNS resolver for '{}': {}", container_name, strerror(errno));
|
||||
return std::nullopt;
|
||||
}
|
||||
if (resolver_pid == 0) {
|
||||
std::vector<char*> argv_c;
|
||||
argv_c.reserve(argv.size() + 1);
|
||||
for (auto& arg : argv) {
|
||||
argv_c.push_back(arg.data());
|
||||
}
|
||||
argv_c.push_back(nullptr);
|
||||
execv(argv_c[0], argv_c.data());
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
// dnsmasq daemonizes on its own by default (no --no-daemon/-d given
|
||||
// above -- **real bug found by testing, not assumed**: dnsmasq only
|
||||
// writes --pid-file at all when it's actually daemonizing; -d/--no-daemon
|
||||
// is documented as suppressing it, confirmed directly: dnsmasq started
|
||||
// successfully and read the hosts file (visible in its own log output)
|
||||
// but the pid-file poll below timed out every time with --no-daemon
|
||||
// given). That means the process this fork() just created -- nsenter,
|
||||
// exec()'d in-place into dnsmasq -- is only the *intermediate* process:
|
||||
// it forks again internally to daemonize and exits once the real,
|
||||
// long-running daemon is ready, so it must be reaped here (not tracked
|
||||
// as the resolver's own pid) before ever polling for the pid-file.
|
||||
int intermediate_status = 0;
|
||||
waitpid(resolver_pid, &intermediate_status, 0);
|
||||
|
||||
// Bounded poll for dnsmasq's own --pid-file to appear -- it's only
|
||||
// written after successfully parsing arguments and binding its socket,
|
||||
// so its existence is a reliable "started cleanly" signal.
|
||||
bool started = false;
|
||||
for (int elapsed_ms = 0; elapsed_ms <= 2000; elapsed_ms += 20) {
|
||||
if (std::filesystem::exists(state_path) && std::filesystem::file_size(state_path, ec) > 0) {
|
||||
started = true;
|
||||
break;
|
||||
}
|
||||
struct timespec ts {
|
||||
0, 20L * 1000000L
|
||||
};
|
||||
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
|
||||
}
|
||||
}
|
||||
if (!started) {
|
||||
spdlog::error("DNS resolver for '{}' did not start within the timeout", container_name);
|
||||
if (internal_hosts_file) {
|
||||
std::filesystem::remove(*internal_hosts_file, ec);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// The daemon's own real pid, written by itself -- not the intermediate
|
||||
// process reaped above, and not necessarily the same value (that's the
|
||||
// whole point of it daemonizing).
|
||||
std::ifstream in(state_path);
|
||||
pid_t daemon_pid = 0;
|
||||
if (!(in >> daemon_pid) || daemon_pid <= 0) {
|
||||
spdlog::error("DNS resolver for '{}' wrote an unreadable pid file", container_name);
|
||||
if (internal_hosts_file) {
|
||||
std::filesystem::remove(*internal_hosts_file, ec);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return DnsResolverHandle{daemon_pid, internal_hosts_file};
|
||||
}
|
||||
|
||||
void stop_dns_resolver(const DnsResolverHandle& handle) {
|
||||
if (kill(handle.pid, SIGTERM) != 0 && errno != ESRCH) {
|
||||
spdlog::warn("failed to signal DNS resolver pid {}: {}", handle.pid, strerror(errno));
|
||||
}
|
||||
int status = 0;
|
||||
waitpid(handle.pid, &status, 0);
|
||||
if (handle.internal_hosts_file) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(*handle.internal_hosts_file, ec);
|
||||
}
|
||||
}
|
||||
|
||||
void remove_dns_resolver_record(std::string_view container_name, pid_t pid) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(dns_resolver_state_path(container_name, pid), ec);
|
||||
}
|
||||
|
||||
std::vector<std::string> clean_stale_dns_resolvers() {
|
||||
std::vector<std::string> cleaned;
|
||||
auto sessions = list_sessions();
|
||||
|
||||
{
|
||||
auto dir = xdg_state_dir() / "dns-resolvers";
|
||||
std::error_code ec;
|
||||
auto it = std::filesystem::directory_iterator(dir, ec);
|
||||
if (!ec) {
|
||||
for (const auto& entry : it) {
|
||||
std::string filename = entry.path().filename().string();
|
||||
if (session_still_running(sessions, filename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::ifstream in(entry.path());
|
||||
pid_t resolver_pid = 0;
|
||||
if ((in >> resolver_pid) && resolver_pid > 0) {
|
||||
if (kill(resolver_pid, SIGKILL) != 0 && errno != ESRCH) {
|
||||
spdlog::warn("failed to kill stale DNS resolver pid {}: {}", resolver_pid, strerror(errno));
|
||||
}
|
||||
}
|
||||
|
||||
std::error_code remove_ec;
|
||||
std::filesystem::remove(entry.path(), remove_ec);
|
||||
if (remove_ec) {
|
||||
spdlog::warn("failed to remove stale DNS resolver record {}: {}", entry.path().string(),
|
||||
remove_ec.message());
|
||||
continue;
|
||||
}
|
||||
cleaned.push_back(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
auto dns_hosts_root = xdg_state_dir() / "dns-hosts";
|
||||
std::error_code ec;
|
||||
auto network_dirs = std::filesystem::directory_iterator(dns_hosts_root, ec);
|
||||
if (!ec) {
|
||||
for (const auto& network_dir : network_dirs) {
|
||||
std::error_code is_dir_ec;
|
||||
if (!network_dir.is_directory(is_dir_ec)) {
|
||||
continue;
|
||||
}
|
||||
std::error_code inner_ec;
|
||||
auto records = std::filesystem::directory_iterator(network_dir.path(), inner_ec);
|
||||
if (inner_ec) {
|
||||
continue;
|
||||
}
|
||||
for (const auto& entry : records) {
|
||||
std::string filename = entry.path().filename().string();
|
||||
if (session_still_running(sessions, filename)) {
|
||||
continue;
|
||||
}
|
||||
std::error_code remove_ec;
|
||||
std::filesystem::remove(entry.path(), remove_ec);
|
||||
if (remove_ec) {
|
||||
spdlog::warn("failed to remove stale DNS host record {}: {}", entry.path().string(),
|
||||
remove_ec.message());
|
||||
continue;
|
||||
}
|
||||
cleaned.push_back(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
auto dir = xdg_state_dir() / "dns-internal-hosts";
|
||||
std::error_code ec;
|
||||
auto it = std::filesystem::directory_iterator(dir, ec);
|
||||
if (!ec) {
|
||||
for (const auto& entry : it) {
|
||||
std::string filename = entry.path().filename().string();
|
||||
if (session_still_running(sessions, filename)) {
|
||||
continue;
|
||||
}
|
||||
std::error_code remove_ec;
|
||||
std::filesystem::remove(entry.path(), remove_ec);
|
||||
if (remove_ec) {
|
||||
spdlog::warn("failed to remove stale host.containers.internal file {}: {}", entry.path().string(),
|
||||
remove_ec.message());
|
||||
continue;
|
||||
}
|
||||
cleaned.push_back(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// 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.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "network_join.h"
|
||||
|
||||
// $XDG_STATE_HOME/slocker-lite/dns-resolv.conf -- a single, static,
|
||||
// idempotently-generated file (content is always just
|
||||
// "nameserver 127.0.0.1\n", written once on first use, since the per-session
|
||||
// resolver started by start_dns_resolver() below always binds there
|
||||
// regardless of which networks a given session joined) that build_bwrap_args()
|
||||
// (bwrap.h) bind-mounts read-only over the sandbox's own /etc/resolv.conf
|
||||
// whenever a network was requested and dnsmasq is available. Returns nullopt
|
||||
// (logging why) if the file can't be created/written.
|
||||
std::optional<std::filesystem::path> ensure_generated_resolv_conf();
|
||||
|
||||
// True if the `dnsmasq` binary is found in PATH -- gates every function
|
||||
// below: DNS resolution is a best-effort convenience on top of otherwise
|
||||
// working networking, not a hard dependency (unlike `ip`/`nsenter`, which
|
||||
// networking can't function without at all), so a missing `dnsmasq` should
|
||||
// degrade with a warning rather than fail -r/--run outright. Not referenced
|
||||
// anywhere else in this project, and not in README.md's runtime-dependency
|
||||
// list -- a genuinely new, but optional, one.
|
||||
bool is_dnsmasq_available();
|
||||
|
||||
// $XDG_STATE_HOME/slocker-lite/dns-hosts/<sanitized-network-name> -- one
|
||||
// directory per network, watched (via dnsmasq's own `--hostsdir`, inotify-
|
||||
// based, no reload signal needed) by every session's own resolver that
|
||||
// joined that network. Exported so start_dns_resolver() below can pass it
|
||||
// straight through as a `--hostsdir` argument.
|
||||
std::filesystem::path dns_hosts_dir(const std::string& network_name);
|
||||
|
||||
// Writes "<ip> <hostname>\n" (plain /etc/hosts syntax, exactly what
|
||||
// dnsmasq's --hostsdir expects) to
|
||||
// dns_hosts_dir(network_name)/<sanitized-container-name>-<pid> -- one record
|
||||
// per (network, session), so other sessions' own resolvers on the same
|
||||
// network can discover this one. Creates dns_hosts_dir(network_name) first
|
||||
// if it doesn't exist yet. Best-effort: logs a warning and does nothing
|
||||
// further on failure, never fatal, matching every other piece of session
|
||||
// state tracking in this project (record_port_forwards(), record_tap_relays()).
|
||||
void record_dns_host(const std::string& network_name, std::string_view container_name, pid_t pid,
|
||||
const std::string& ip, const std::string& hostname);
|
||||
|
||||
// Removes the record written by record_dns_host() for (network_name,
|
||||
// container_name, pid). A no-op if no such record exists.
|
||||
void remove_dns_host_record(const std::string& network_name, std::string_view container_name, pid_t pid);
|
||||
|
||||
// One per-session dnsmasq process.
|
||||
struct DnsResolverHandle {
|
||||
pid_t pid;
|
||||
// Set only when host.containers.internal was configured (i.e. at least
|
||||
// one `extern` network was joined) -- the small, session-private hosts
|
||||
// file start_dns_resolver() generated for it (see its own doc comment
|
||||
// for why this is a plain --addn-hosts file rather than dnsmasq's
|
||||
// --address=/name/ip option), removed by stop_dns_resolver().
|
||||
std::optional<std::filesystem::path> internal_hosts_file;
|
||||
};
|
||||
|
||||
// Starts one dnsmasq instance for this session, entered into the
|
||||
// *container's own* network namespace (named by `ns_pid` --
|
||||
// sandbox_process.h's resolve_namespace_pid(), the same helper
|
||||
// network_join.cpp/exec_session.cpp already use), bound to 127.0.0.1:53
|
||||
// inside it.
|
||||
//
|
||||
// **Deliberately one instance per session, not one per network** -- see
|
||||
// docs/networking-design.md's own "DNS resolution" section for the full
|
||||
// reasoning: a container joined to two networks would otherwise list two
|
||||
// `nameserver` lines in /etc/resolv.conf, and standard stub resolvers
|
||||
// (glibc/musl/busybox) don't fall through to the next nameserver on
|
||||
// NXDOMAIN, only on timeout -- a name that exists only on the *second*
|
||||
// network would silently fail to resolve. Running one instance per session
|
||||
// instead, configured (via dnsmasq's own repeatable `--hostsdir=<dir>`) to
|
||||
// watch every network *that specific container* joined, avoids the problem
|
||||
// entirely: there's only ever one nameserver (127.0.0.1) to ask, and it
|
||||
// already knows about every network the caller could possibly mean.
|
||||
//
|
||||
// `--no-resolv --no-hosts`: this process, after setns(CLONE_NEWNET) only
|
||||
// (not a new mount namespace), still sees the *host's* own filesystem --
|
||||
// without these dnsmasq would pick up the host's own unrelated
|
||||
// /etc/resolv.conf/etc/hosts content. dnsmasq is deliberately let daemonize
|
||||
// on its own (no --no-daemon) -- **real bug found by testing, not
|
||||
// assumed**: dnsmasq only writes --pid-file at all while actually
|
||||
// daemonizing; -d/--no-daemon suppresses it entirely, confirmed directly
|
||||
// (dnsmasq started and read the hosts file successfully, per its own log
|
||||
// output, but the pid-file this function polls for below never appeared).
|
||||
// The forked/exec'd process (nsenter, in-place exec'd into dnsmasq) is thus
|
||||
// only the *intermediate* process -- it forks again internally and exits
|
||||
// once the real, long-running daemon is ready -- so it's reaped immediately
|
||||
// and the returned handle's pid is read back from the pid-file itself (the
|
||||
// daemon's own real, final pid), not assumed to be the forked pid.
|
||||
//
|
||||
// `host.containers.internal` (podman's own convention) is added, only when
|
||||
// `joined` contains at least one `extern` network -- the *first* one, in
|
||||
// `-n` order -- resolving to that network's own gateway address
|
||||
// (ipv4_gateway_address(), network_subnet.h), via a small, session-private
|
||||
// `--addn-hosts=<file>` (plain /etc/hosts syntax, same as the --hostsdir
|
||||
// records above) rather than dnsmasq's own `--address=/name/ip` option.
|
||||
// **Real bug found by testing, not assumed**: with `--address`, an AAAA
|
||||
// query for host.containers.internal came back REFUSED even with
|
||||
// --filter-AAAA given (which correctly turns REFUSED into a clean "no
|
||||
// data" answer for ordinary --hostsdir entries) -- --address records
|
||||
// apparently aren't treated the same way internally. Using the same
|
||||
// hosts-file mechanism as everything else sidesteps the inconsistency
|
||||
// rather than working around it. Omitted entirely for an intern-only
|
||||
// session (also REFUSED, not NXDOMAIN as originally assumed -- this
|
||||
// resolver has no upstream server at all, and dnsmasq's chosen response for
|
||||
// "I can't determine this" is REFUSED, not authoritative non-existence --
|
||||
// matching this project's existing "no route out" intent there either way).
|
||||
//
|
||||
// Startup is confirmed by a bounded poll (same shape as network_join.cpp's
|
||||
// own wait_for_isolated_net_namespace()) for dnsmasq's own `--pid-file` to
|
||||
// appear -- it's only written after dnsmasq has successfully parsed its
|
||||
// arguments and bound its socket, so its existence is a reliable "started
|
||||
// cleanly" signal; no custom handshake protocol is needed since this hands
|
||||
// off entirely to an external binary that knows nothing about this project.
|
||||
//
|
||||
// Returns nullopt (logging why) if `dnsmasq`/`nsenter` aren't found, the
|
||||
// fork fails, or the pid-file never appears within the timeout.
|
||||
std::optional<DnsResolverHandle> start_dns_resolver(std::string_view container_name, pid_t pid, pid_t ns_pid,
|
||||
const std::vector<JoinedNetwork>& joined);
|
||||
|
||||
// Stops a resolver started by start_dns_resolver(): SIGTERM, then waitpid(),
|
||||
// then removes handle.internal_hosts_file if set -- no other device or
|
||||
// host-side state to remove (unlike a tap relay's host-side tap device),
|
||||
// since this only ever binds to loopback inside the container's own,
|
||||
// already-ephemeral namespace. Does *not* remove
|
||||
// dns_resolver_state_path(container_name, pid) itself -- see
|
||||
// remove_dns_resolver_record() below, called separately, same two-calls
|
||||
// shape as stop_tap_relay()/remove_tap_relay_record().
|
||||
void stop_dns_resolver(const DnsResolverHandle& handle);
|
||||
|
||||
// Removes the pid-file dnsmasq itself wrote at
|
||||
// dns_resolver_state_path(container_name, pid) -- called once this
|
||||
// process's own stop_dns_resolver() has already run, so
|
||||
// clean_stale_dns_resolvers() doesn't later find it, decide (correctly)
|
||||
// that the session isn't running any more, and try to kill an already-
|
||||
// stopped pid a second time. A no-op if no such file exists.
|
||||
void remove_dns_resolver_record(std::string_view container_name, pid_t pid);
|
||||
|
||||
// $XDG_STATE_HOME/slocker-lite/dns-resolvers/<sanitized-container-name>-<pid>
|
||||
// -- the *same* path passed to dnsmasq as its own --pid-file, so dnsmasq's
|
||||
// own write of its pid there doubles as this project's crash-orphan record;
|
||||
// no separate write step is needed. Same naming scheme as
|
||||
// tap_relay_state_path()/port_forward_state_path() (pid_file.h's
|
||||
// xdg_state_dir()/sanitize_for_filename()), so clean_stale_dns_resolvers()
|
||||
// below can cross-reference it against list_sessions() the same way.
|
||||
std::filesystem::path dns_resolver_state_path(std::string_view container_name, pid_t pid);
|
||||
|
||||
// Implements --clean-processes's sweep for both pieces of DNS state a
|
||||
// crashed (or killed) slocker-lite could leave behind -- neither is torn
|
||||
// down by bwrap's own --die-with-parent, since neither is a descendant of
|
||||
// the sandboxed command:
|
||||
// 1. dns-resolvers/<container>-<pid>: for each whose filename doesn't
|
||||
// match a currently-*running* session (list_sessions(), pid_file.h),
|
||||
// SIGKILLs the pid it names (best-effort -- ESRCH isn't an error) and
|
||||
// removes the file.
|
||||
// 2. dns-hosts/<network>/<container>-<pid>: same staleness check, just
|
||||
// removes the record file -- dnsmasq's own inotify watch (wherever
|
||||
// some *other*, still-running session's resolver is watching that
|
||||
// directory) notices the removal on its own, no extra live-state to
|
||||
// tear down.
|
||||
// 3. dns-internal-hosts/<container>-<pid>: the host.containers.internal
|
||||
// file start_dns_resolver() generates per session (see its own doc
|
||||
// comment) -- same staleness check, just removes the file (nothing
|
||||
// else references it once its own resolver process is gone).
|
||||
// A record whose session is still running is left completely alone either
|
||||
// way. Returns one entry per record actually removed (as "<container>-<pid>",
|
||||
// same as clean_stale_tap_relays()'s own return shape) -- a session that
|
||||
// joined N networks can appear once for its resolver process and once per
|
||||
// network's own host record, the same "each sweep reports its own findings
|
||||
// independently" convention clean_processes_command() (commands.cpp)
|
||||
// already uses across its three existing sweeps.
|
||||
|
||||
std::vector<std::string> clean_stale_dns_resolvers();
|
||||
@@ -28,7 +28,18 @@
|
||||
#include <fmt/core.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#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 "config_file.h"
|
||||
#include "network_dns.h"
|
||||
#include "network_tap_relay.h"
|
||||
#include "persistent_netns.h"
|
||||
#include "process.h"
|
||||
@@ -231,6 +242,249 @@ bool test_tap_relay() {
|
||||
return ok;
|
||||
}
|
||||
|
||||
// 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) so a failure's own spdlog::debug below
|
||||
// can say *why* -- 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) {
|
||||
if (WIFEXITED(status)) {
|
||||
spdlog::debug("self-test: DNS query for '{}' got no answer, child exit code {}", name,
|
||||
WEXITSTATUS(status));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
out_ip.assign(buf, static_cast<size_t>(n));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exercises network_dns.h's create/answer/teardown cycle end to end: a
|
||||
// throwaway network namespace (same fork+unshare(CLONE_NEWNET)+pause()
|
||||
// technique test_tap_relay() 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 (not a failure) if dnsmasq isn't
|
||||
// installed, same as the whole suite's own root-only skip.
|
||||
bool test_dns_resolver() {
|
||||
if (!is_dnsmasq_available()) {
|
||||
fmt::print("skipping DNS resolver test (dnsmasq not found in PATH)\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::string test_network_name = "selftest-dns";
|
||||
const std::string test_hostname = "selftest-peer";
|
||||
const std::string test_ip = "10.99.99.2";
|
||||
|
||||
pid_t container_pid = fork();
|
||||
if (container_pid < 0) {
|
||||
spdlog::error("self-test: failed to fork throwaway container namespace holder for DNS test");
|
||||
return false;
|
||||
}
|
||||
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 = false;
|
||||
for (int elapsed_ms = 0; elapsed_ms <= 1000; elapsed_ms += 20) {
|
||||
if (namespace_isolated(getpid(), container_pid, "net")) {
|
||||
isolated = true;
|
||||
break;
|
||||
}
|
||||
struct timespec ts {
|
||||
0, 20L * 1000000L
|
||||
};
|
||||
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
|
||||
}
|
||||
}
|
||||
if (!isolated) {
|
||||
spdlog::error("self-test: throwaway container namespace never isolated (DNS test)");
|
||||
kill(container_pid, SIGKILL);
|
||||
int reap_status = 0;
|
||||
waitpid(container_pid, &reap_status, 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(dns_hosts_dir(test_network_name), ec);
|
||||
record_dns_host(test_network_name, "selftest-peer-container", 1, test_ip, test_hostname);
|
||||
|
||||
bool ok = true;
|
||||
NetworkEntry test_network{test_network_name, NetworkKind::intern, "", false, "", true};
|
||||
JoinedNetwork joined_network{test_network, test_ip, std::nullopt};
|
||||
auto resolver = start_dns_resolver("selftest-dns-session", container_pid, container_pid, {joined_network});
|
||||
if (!resolver) {
|
||||
spdlog::error("self-test: failed to start DNS resolver");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
std::string answered_ip;
|
||||
if (!query_dns_a_record(container_pid, test_hostname, answered_ip) || answered_ip != test_ip) {
|
||||
spdlog::error("self-test: DNS resolver did not answer '{}' correctly (got '{}', expected '{}')",
|
||||
test_hostname, answered_ip, test_ip);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolver) {
|
||||
stop_dns_resolver(*resolver);
|
||||
}
|
||||
remove_dns_resolver_record("selftest-dns-session", container_pid);
|
||||
remove_dns_host_record(test_network_name, "selftest-peer-container", 1);
|
||||
std::filesystem::remove_all(dns_hosts_dir(test_network_name), ec);
|
||||
kill(container_pid, SIGKILL);
|
||||
int status = 0;
|
||||
waitpid(container_pid, &status, 0);
|
||||
|
||||
if (ok) {
|
||||
fmt::print("dns-resolver create/answer/teardown: OK\n");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int run_self_tests() {
|
||||
@@ -244,6 +498,7 @@ int run_self_tests() {
|
||||
|
||||
bool ok = test_persistent_netns();
|
||||
ok = test_tap_relay() && ok;
|
||||
ok = test_dns_resolver() && ok;
|
||||
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user