From 49d9c0ec58cd42a36e90aabf5b8fe4842e159b24 Mon Sep 17 00:00:00 2001 From: xz-dev Date: Thu, 10 Sep 2026 10:32:54 +0800 Subject: [PATCH 1/2] fix(linux): stop chmod'ing resolv.conf to 0444 in direct-write path The direct-write fallback in setup_resolv_conf (used when bind-mounting is unavailable, e.g. inside Docker without CAP_SYS_ADMIN) called fchmod(fd, 0444) on /etc/resolv.conf via write_nameserver. In containers, /etc/resolv.conf is Docker's bind-mounted host file (/var/lib/docker/containers//resolv.conf), so the mode change persists across restarts. On the next start, open(O_WRONLY) on the now-0444 file fails with EACCES unless the process holds CAP_DAC_OVERRIDE - which capability-dropped containers don't. With --exit-on-fatal-error this becomes a permanent crash loop that only recreating the container can fix. The 0444 does not even serve its stated purpose on this path: it is meant to keep NetworkManager from overwriting resolv.conf, but NM runs as root with CAP_DAC_OVERRIDE and can overwrite a 0444 file anyway. The real protection is the MS_RDONLY bind-mount remount on the mount path. On the direct-write path the chmod protects nothing and only guarantees the next start fails. Two changes: - write_nameserver: drop the fchmod(0444). The tempfile on the mount path doesn't need it either (it is bind-mounted read-only). - direct-write branch: on EACCES, restore mode 0644 and reopen, so deployments already poisoned by an older version heal themselves on the first restart instead of crash-looping. Verified with an integration test (test-restart-eacces.sh) against Docker Engine 29.1.3 with --cap-drop ALL --cap-add NET_ADMIN: - stock v0.8.3: host resolv.conf becomes 0444 after first run - patched: mode stays 0644, restart healthy, and a pre-poisoned 0444 file is healed back to 0644 with no EACCES --- src/linux.rs | 16 ++++++++++-- test-restart-eacces.sh | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100755 test-restart-eacces.sh diff --git a/src/linux.rs b/src/linux.rs index e789843..4d5f054 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -316,7 +316,6 @@ fn write_buffer_to_fd(fd: std::os::fd::BorrowedFd<'_>, data: &[u8]) -> Result<() fn write_nameserver(fd: std::os::fd::BorrowedFd<'_>, tun_gateway: Option) -> Result<()> { let tun_gateway = tun_gateway.unwrap_or_else(|| "198.18.0.1".parse().unwrap()); let data = format!("nameserver {tun_gateway}\n"); - nix::sys::stat::fchmod(fd.as_fd(), nix::sys::stat::Mode::from_bits(0o444).unwrap())?; write_buffer_to_fd(fd, data.as_bytes())?; Ok(()) } @@ -368,8 +367,21 @@ fn setup_resolv_conf(restore: &mut TproxyStateInner) -> Result<()> { restore.restore_resolvconf_content = Some(fs::read(ETC_RESOLV_CONF_FILE)?); + // Older versions chmod'ed /etc/resolv.conf to 0444 on this path, which made the + // write here fail with EACCES on every subsequent start in containers that + // drop CAP_DAC_OVERRIDE (e.g. Docker's bind-mounted resolv.conf). Restore + // writability before reopening. + let write_mode = nix::sys::stat::Mode::from_bits(0o644).unwrap(); let flags = nix::fcntl::OFlag::O_WRONLY | nix::fcntl::OFlag::O_CLOEXEC | nix::fcntl::OFlag::O_TRUNC; - let fd = nix::fcntl::open(ETC_RESOLV_CONF_FILE, flags, nix::sys::stat::Mode::from_bits(0o644).unwrap())?; + let fd = match nix::fcntl::open(ETC_RESOLV_CONF_FILE, flags, write_mode) { + Ok(fd) => fd, + Err(nix::errno::Errno::EACCES) => { + // Recover from the 0444 mode left behind by older versions (see comment above). + fs::set_permissions(ETC_RESOLV_CONF_FILE, Permissions::from_mode(0o644))?; + nix::fcntl::open(ETC_RESOLV_CONF_FILE, flags, write_mode)? + } + Err(err) => return Err(err.into()), + }; write_nameserver(fd.as_fd(), tun_gateway)?; } Ok(()) diff --git a/test-restart-eacces.sh b/test-restart-eacces.sh new file mode 100755 index 0000000..24c4b4c --- /dev/null +++ b/test-restart-eacces.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Integration test: tun2proxy container restart EACCES (tproxy-config issue) +# +# Validates two properties of the tproxy-config resolv.conf direct-write path: +# 1. After a fresh container start, the host-side resolv.conf file keeps mode 0644 +# (old versions chmod'ed it to 0444, which broke every subsequent restart). +# 2. After `docker restart`, the container must come back up healthy (restartable). +# +# Usage: IMAGE= [PROXY=] ./test-restart-eacces.sh +set -euo pipefail + +IMAGE="${IMAGE:?set IMAGE to the tun2proxy image under test}" +PROXY="${PROXY:-192.0.2.1:1080}" # dummy proxy target; reachability irrelevant for this test +NAME="t2p-restart-test" + +cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "PASS: $*"; } + +CIDFILE=$(mktemp) +cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; rm -f "$CIDFILE"; } + +# Phase 1: fresh create must start (tun2proxy exits on fatal error if setup fails) +docker run -d --name "$NAME" --device /dev/net/tun \ + --cap-drop ALL --cap-add NET_ADMIN \ + "$IMAGE" --proxy "http://$PROXY" --dns virtual --exit-on-fatal-error \ + >/dev/null + +# Wait up to 15s for either a healthy run or a crash +sleep 5 +STATE1=$(docker inspect "$NAME" --format '{{.State.Status}} {{.State.ExitCode}}' 2>/dev/null || echo "gone") +echo "after create: $STATE1" +[ "$STATE1" = "running 0" ] || [ "${STATE1%% *}" = "running" ] || fail "fresh container not running: $STATE1" + +# Phase 2: host-side resolv.conf mode must still be 0644 (not poisoned to 0444) +CID=$(docker inspect "$NAME" --format '{{.Id}}') +MODE=$(stat -c %a "/var/lib/docker/containers/$CID/resolv.conf") +echo "host resolv.conf mode after first run: $MODE" +[ "$MODE" = "644" ] || fail "resolv.conf poisoned: mode $MODE (expected 644)" + +# Phase 3: restart must come back up (old behavior: EACCES crash loop) +docker restart "$NAME" >/dev/null +sleep 5 +STATE2=$(docker inspect "$NAME" --format '{{.State.Status}} {{.State.ExitCode}}' 2>/dev/null || echo "gone") +echo "after restart: $STATE2" +[ "${STATE2%% *}" = "running" ] || fail "restart failed: $STATE2 (EACCES crash loop?)" + +# Phase 4: double-check no EACCES in logs +if docker logs "$NAME" 2>&1 | grep -q "EACCES"; then + fail "EACCES present in logs after restart" +fi + +pass "create healthy, resolv.conf 0644 preserved, restart healthy, no EACCES" \ No newline at end of file From 576f3e6435d910574d6db3c40f3fbaf303f67654 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Mon, 21 Sep 2026 13:55:38 +0800 Subject: [PATCH 2/2] test: exercise 0444 heal path; resolve Docker root via docker info - Resolve the host resolv.conf path via `docker info --format '{{.DockerRootDir}}'` instead of hardcoding /var/lib/docker, so the test works with custom data-root and rootless Docker. - Pre-poison the host file to 0444 before restart, so the EACCES heal-to-0644 branch in setup_resolv_conf is actually exercised (upgrade scenario), and assert the mode is restored. - Drop unused CIDFILE/mktemp leftover. Addresses Copilot review feedback on PR #36. --- test-restart-eacces.sh | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/test-restart-eacces.sh b/test-restart-eacces.sh index 24c4b4c..ab4d58e 100755 --- a/test-restart-eacces.sh +++ b/test-restart-eacces.sh @@ -4,7 +4,8 @@ # Validates two properties of the tproxy-config resolv.conf direct-write path: # 1. After a fresh container start, the host-side resolv.conf file keeps mode 0644 # (old versions chmod'ed it to 0444, which broke every subsequent restart). -# 2. After `docker restart`, the container must come back up healthy (restartable). +# 2. If the host-side file was left at 0444 by an older version (upgrade case), +# a restart must heal it back to 0644 and come back up healthy. # # Usage: IMAGE= [PROXY=] ./test-restart-eacces.sh set -euo pipefail @@ -19,9 +20,6 @@ trap cleanup EXIT fail() { echo "FAIL: $*" >&2; exit 1; } pass() { echo "PASS: $*"; } -CIDFILE=$(mktemp) -cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; rm -f "$CIDFILE"; } - # Phase 1: fresh create must start (tun2proxy exits on fatal error if setup fails) docker run -d --name "$NAME" --device /dev/net/tun \ --cap-drop ALL --cap-add NET_ADMIN \ @@ -35,21 +33,29 @@ echo "after create: $STATE1" [ "$STATE1" = "running 0" ] || [ "${STATE1%% *}" = "running" ] || fail "fresh container not running: $STATE1" # Phase 2: host-side resolv.conf mode must still be 0644 (not poisoned to 0444) +DOCKER_ROOT=$(docker info --format '{{.DockerRootDir}}') CID=$(docker inspect "$NAME" --format '{{.Id}}') -MODE=$(stat -c %a "/var/lib/docker/containers/$CID/resolv.conf") +HOST_RESOLV="$DOCKER_ROOT/containers/$CID/resolv.conf" +MODE=$(stat -c %a "$HOST_RESOLV") echo "host resolv.conf mode after first run: $MODE" [ "$MODE" = "644" ] || fail "resolv.conf poisoned: mode $MODE (expected 644)" -# Phase 3: restart must come back up (old behavior: EACCES crash loop) +# Phase 3: poison host file to 0444 (upgrade scenario) then restart; +# the EACCES heal path must restore 0644 and the container must come back up +chmod 0444 "$HOST_RESOLV" 2>/dev/null || sudo chmod 0444 "$HOST_RESOLV" docker restart "$NAME" >/dev/null sleep 5 STATE2=$(docker inspect "$NAME" --format '{{.State.Status}} {{.State.ExitCode}}' 2>/dev/null || echo "gone") echo "after restart: $STATE2" [ "${STATE2%% *}" = "running" ] || fail "restart failed: $STATE2 (EACCES crash loop?)" +MODE2=$(stat -c %a "$HOST_RESOLV") +echo "host resolv.conf mode after restart: $MODE2" +[ "$MODE2" = "644" ] || fail "heal failed: mode $MODE2 (expected 644)" + # Phase 4: double-check no EACCES in logs if docker logs "$NAME" 2>&1 | grep -q "EACCES"; then fail "EACCES present in logs after restart" fi -pass "create healthy, resolv.conf 0644 preserved, restart healthy, no EACCES" \ No newline at end of file +pass "create healthy, resolv.conf 0644 preserved, restart heals 0444->0644, no EACCES" \ No newline at end of file