fix: reclaim root-owned container files so docker nodes and projects can be deleted

The stop-time permission pass necessarily runs before the container's
processes exit, so files written during the shutdown window (syslog
archives, trace flushes) and after any SIGKILL path stay owned by root
on the host. An unprivileged server can neither chown nor delete them,
which broke node deletion and project deletion.

Reclaim them through the only privilege door a non-root server has:
a one-shot throwaway container of the node's own image, entrypoint
overridden to the GNS3 busybox (nothing of the guest boots), chowning
the node directory back to the server user. It runs at the end of
close() — project deletion rmtrees the directory right after the nodes
close, so close must leave a clean tree — and as a retry fallback in
delete(). The helper resolves the image by its create-time ID with
--pull=never, so a stale or retagged image name cannot turn into a
registry pull attempt.
This commit is contained in:
YueGuobin 2026-09-12 23:04:28 +08:00
parent 100cb327bd
commit 54d9d7c08f
No known key found for this signature in database
2 changed files with 241 additions and 1 deletions

View File

@ -35,11 +35,13 @@ from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.utils.asyncio.raw_command_server import AsyncioRawCommandServer
from gns3server.utils.asyncio import wait_for_file_creation
from gns3server.utils.asyncio import monitor_process
from gns3server.utils.asyncio import wait_run_in_executor
from gns3server.utils.get_resource import get_resource
from gns3server.utils.hostname import is_rfc1123_hostname_valid
from gns3server.utils import macaddress_to_int, int_to_macaddress
from gns3server.compute.ubridge.ubridge_error import UbridgeError, UbridgeNamespaceError
from gns3server.compute.compute_error import ComputeError
from ..base_node import BaseNode
from ..adapters.ethernet_adapter import EthernetAdapter
@ -148,6 +150,9 @@ class DockerVM(BaseNode):
self._console_websocket = None
self.extra_hosts = extra_hosts
self._extra_volumes = extra_volumes or []
# Image ID captured at create time (see _mount_binds): unlike the
# image tag it keeps resolving even if the tag is removed later.
self._image_id = None
self._extra_configs = extra_configs or []
self._memory = memory
self._cpus = cpus
@ -448,6 +453,7 @@ class DockerVM(BaseNode):
"Target": "/gns3",
"ReadOnly": True
}]
self._image_id = image_info.get("Id") or self._image_id
# We mount our own etc/network
try:
@ -1024,6 +1030,101 @@ class DockerVM(BaseNode):
else:
self._permissions_fixed = True
def _directory_has_foreign_files(self, directory):
"""
Synchronous walk (run in an executor): return True as soon as an
entry not owned by the server user is found under ``directory``.
What we are looking for are files the container wrote as root
the unprivileged server can neither chown nor delete them.
"""
uid = os.getuid()
for root, dirs, files in os.walk(directory):
for entry in dirs + files:
try:
if os.stat(os.path.join(root, entry)).st_uid != uid:
return True
except OSError:
# An entry we cannot stat is not ours to delete either;
# treat it as foreign and let the reclaim sort it out.
return True
return False
async def _reclaim_directory_ownership(self, directory):
"""
Best-effort reclaim of files the container left owned by root.
The stop-time permission pass necessarily runs before the
container's processes exit, so anything they write during the
shutdown window (and everything after a SIGKILL path) lands as
root-owned on the host. An unprivileged server cannot chown or
delete those files, but it can ask Docker to run a throwaway
container of the node's own image — entrypoint overridden to the
GNS3 busybox, so nothing of the guest boots and chown the tree
back from the container's root side. Docker is the only privilege
door a non-root server has.
:returns: True when ``directory`` is (now) owned by the server
user, False when the reclaim could not run or failed.
"""
if not os.path.exists(directory):
return True
try:
if not await wait_run_in_executor(self._directory_has_foreign_files, directory):
log.debug(f"Docker container '{self._name}': no foreign-owned files under '{directory}'")
return True
except OSError as e:
log.warning(f"Docker container '{self._name}': could not inspect '{directory}' for root-owned files: {e}")
return False
uid, gid = os.getuid(), os.getgid()
try:
resources_path = self.manager.resources_path()
except OSError as e:
log.warning(f"Docker container '{self._name}': cannot access resources to reclaim '{directory}': {e}")
return False
log.info(f"Docker container '{self._name}': reclaiming root-owned files under '{directory}' via a one-shot container")
# Prefer the image's own chown over the static busybox one: busybox's
# chown dlopens NSS modules from the image, which mismatch the static
# glibc and abort on NOS images whose glibc differs (same reasoning
# as the container-side pass in _fix_permissions). --pull=never keeps
# a stale tag reference from turning into a registry pull attempt,
# and the create-time image ID (when known) is immune to retagging.
image_ref = self._image_id or self._image
try:
process = await asyncio.subprocess.create_subprocess_exec(
"docker", "run", "--rm", "--network", "none", "--pull", "never",
"--entrypoint", "/gns3/bin/busybox",
"-v", f"{resources_path}:/gns3:ro",
"-v", f"{directory}:/target",
image_ref,
"sh", "-c",
"/gns3/bin/busybox chmod -R u+rwX /target"
f" && ( command -v chown >/dev/null 2>&1 && chown {uid}:{gid} -R /target"
f" || /gns3/bin/busybox chown {uid}:{gid} -R /target )",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as e:
log.warning(f"Docker container '{self._name}': could not reclaim ownership of '{directory}': {e}")
return False
try:
_, stderr = await asyncio.wait_for(process.communicate(), timeout=60)
except asyncio.TimeoutError:
process.kill()
await process.communicate()
log.warning(f"Docker container '{self._name}': reclaiming '{directory}' timed out")
return False
if process.returncode != 0:
log.warning(
f"Docker container '{self._name}': reclaiming '{directory}' failed: "
f"{stderr.decode(errors='replace').strip() or f'exit code {process.returncode}'}"
)
return False
return True
async def _start_vnc_process(self, restart=False):
"""
Starts the VNC process.
@ -1350,6 +1451,13 @@ class DockerVM(BaseNode):
if not (await super().close()):
return False
await self.reset()
# Whatever the container's processes wrote after the stop-time
# permission pass (the shutdown window, or any SIGKILL path) is
# still owned by root on the host and would make the node
# directory undeletable for the unprivileged server. Reclaim it
# before close() returns: project deletion rmtrees the directory
# right after the nodes close.
await self._reclaim_directory_ownership(self.working_dir)
async def reset(self, release_nio_udp_ports=True):
@ -1928,4 +2036,20 @@ class DockerVM(BaseNode):
"""
await self.close()
await super().delete()
try:
await super().delete()
except ComputeError as e:
# close() already reclaimed once; the retry covers states where
# it could not run (e.g. Docker was down) and Docker is back now.
if await self._reclaim_directory_ownership(self.working_dir):
try:
await super().delete()
return
except ComputeError:
pass
raise ComputeError(
f"Could not delete the node directory '{self.working_dir}': files left owned by "
f"root could not be reclaimed ({e}). Reclaim them manually with: "
f"docker run --rm -v \"{self.working_dir}\":/target --entrypoint /bin/sh "
f"{self._image} -c 'chown -R {os.getuid()}:{os.getgid()} /target'"
)

View File

@ -22,10 +22,12 @@ import pytest_asyncio
import uuid
import os
from types import SimpleNamespace
from unittest.mock import patch
from tests.utils import asyncio_patch, AsyncioMagicMock
from gns3server.compute.ubridge.ubridge_error import UbridgeNamespaceError
from gns3server.compute.compute_error import ComputeError
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
from gns3server.compute.docker import Docker
@ -2037,6 +2039,120 @@ async def test_fix_permission_not_running(vm):
assert process.wait.called
# ---------------------------------------------------------------------------
# Ownership reclaim for files the container left owned by root
# ---------------------------------------------------------------------------
def _reclaim_proc(returncode=0, stderr=b""):
process = MagicMock()
process.communicate = AsyncioMagicMock(return_value=(b"", stderr))
process.returncode = returncode
process.kill = MagicMock()
return process
@pytest.mark.asyncio
async def test_reclaim_skips_clean_directory(vm, tmp_path):
(tmp_path / "file").write_text("owned by the server user")
with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec:
assert await vm._reclaim_directory_ownership(str(tmp_path)) is True
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_reclaim_skips_missing_directory(vm):
with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec:
assert await vm._reclaim_directory_ownership("/does/not/exist") is True
mock_exec.assert_not_called()
def test_directory_has_foreign_files_detects_foreign_owner(vm, tmp_path):
(tmp_path / "root-owned").write_text("written by the container as root")
assert vm._directory_has_foreign_files(str(tmp_path)) is False
# A directory the unprivileged server user cannot own: pretend every
# stat reports another uid (the walk only uses scandir for recursion).
with patch("os.stat", return_value=SimpleNamespace(st_uid=os.getuid() + 1)):
assert vm._directory_has_foreign_files(str(tmp_path)) is True
@pytest.mark.asyncio
async def test_reclaim_runs_helper_container(vm, tmp_path):
directory = tmp_path / "node"
directory.mkdir()
vm._image_id = "sha256:8731fa5e0f0f"
with patch.object(vm, "_directory_has_foreign_files", return_value=True):
with patch.object(vm.manager, "resources_path", return_value="/gns3-share"):
with patch("asyncio.subprocess.create_subprocess_exec", return_value=_reclaim_proc()) as mock_exec:
assert await vm._reclaim_directory_ownership(str(directory)) is True
args, _ = mock_exec.call_args
assert args[:7] == ("docker", "run", "--rm", "--network", "none", "--pull", "never")
assert args[args.index("--entrypoint") + 1] == "/gns3/bin/busybox"
assert "/gns3-share:/gns3:ro" in args
assert f"{directory}:/target" in args
# The create-time image ID is used as the helper image (immune to retagging)
# and sits right before the CMD ("sh -c …").
assert args[args.index("sh") - 1] == "sha256:8731fa5e0f0f"
script = args[args.index("-c") + 1]
assert "chown" in script and f"{os.getuid()}:{os.getgid()}" in script
assert "chmod -R u+rwX" in script
@pytest.mark.asyncio
async def test_reclaim_fails_when_helper_errors(vm, tmp_path):
directory = tmp_path / "node"
directory.mkdir()
with patch.object(vm, "_directory_has_foreign_files", return_value=True):
with patch.object(vm.manager, "resources_path", return_value="/gns3-share"):
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=_reclaim_proc(returncode=1, stderr=b"chown: failed")):
assert await vm._reclaim_directory_ownership(str(directory)) is False
@pytest.mark.asyncio
async def test_close_reclaims_node_directory(vm, port_manager):
with asyncio_patch("gns3server.compute.docker.DockerVM._get_container_state", return_value="stopped"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
with patch.object(vm, "_reclaim_directory_ownership", new_callable=AsyncioMagicMock) as mock_reclaim:
await vm.close()
mock_reclaim.assert_called_once_with(vm.working_dir)
@pytest.mark.asyncio
async def test_delete_retries_after_reclaim(vm):
with patch.object(vm, "close", new_callable=AsyncioMagicMock):
with patch.object(vm, "_reclaim_directory_ownership", new_callable=AsyncioMagicMock, return_value=True) as mock_reclaim:
# First rmtree hits the root-owned leftovers, the retry (after
# the reclaim) succeeds.
with patch("gns3server.compute.base_node.shutil.rmtree",
side_effect=[OSError("permission denied"), None]) as mock_rmtree:
await vm.delete()
assert mock_rmtree.call_count == 2
mock_reclaim.assert_called_once_with(vm.working_dir)
@pytest.mark.asyncio
async def test_delete_reports_root_files_when_reclaim_fails(vm):
with patch.object(vm, "close", new_callable=AsyncioMagicMock):
with patch.object(vm, "_reclaim_directory_ownership", new_callable=AsyncioMagicMock, return_value=False):
with patch("gns3server.compute.base_node.shutil.rmtree",
side_effect=OSError("permission denied")):
with pytest.raises(ComputeError, match="owned by root"):
await vm.delete()
@pytest.mark.asyncio
async def test_read_console_output_with_binary_mode(vm):