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:
2026-09-03 15:14:11 +00:00
parent e7ef5428d0
commit bd3bc27fc4
9 changed files with 929 additions and 12 deletions
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -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
View File
@@ -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
+380
View File
@@ -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;
}
+196
View File
@@ -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();
+255
View File
@@ -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;
}