From 347537f1b36884707a565a20ad734c19dfdfa9f1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 14 Aug 2026 01:12:21 +0800 Subject: [PATCH] docker: mask systemd-udevd in privileged containers (GNS3_MASK_UDEV) A privileged systemd-based NOS container (Cisco XRd boots /usr/sbin/init) runs systemd-udevd, which on startup coldplugs every device it can reach. In privileged mode that includes the HOST's USB/input/audio/disk devices, so every XRd start reconnects USB, mutes audio, and disrupts the host journal -- highly disruptive on Linux desktops (caught in the act: the container's udevd was even rescanning the host BTRFS root device). XRd doesn't need udev (its interfaces are pre-created by GNS3 veth and mapped via XR_INTERFACES). Add two opt-in env vars, consumed host-side at container create time in the inherited DockerVM.create (so VendorDockerVM nodes get it too): GNS3_MASK_UDEV=1 -> bind /dev/null over the udevd unit, its two activation sockets, and the coldplug/settle trigger services GNS3_MASK_SYSTEMD=u1,u2 -> bind /dev/null over arbitrary units in /etc/systemd/system/ (comma/semicolon list) Only injected when set, so ordinary nodes are unaffected. --- gns3server/compute/docker/docker_vm.py | 38 ++++++++++++++++++++++++++ tests/compute/docker/test_docker_vm.py | 23 ++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index a2980f359..382db3690 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -69,6 +69,17 @@ class DockerVM(BaseNode): :param extra_volumes: Additional directories to make persistent """ + # systemd units masked by GNS3_MASK_UDEV=1: the udev daemon, its activation + # sockets and the coldplug/settle triggers. Masking them stops a privileged + # systemd container from replaying device events on the host. + _UDEV_UNITS = ( + "systemd-udevd.service", + "systemd-udevd-control.socket", + "systemd-udevd-kernel.socket", + "systemd-udev-trigger.service", + "systemd-udev-settle.service", + ) + def __init__( self, name, @@ -542,6 +553,33 @@ class DockerVM(BaseNode): devices = self._format_devices(line.split("=", 1)[1]) if devices: params["HostConfig"]["Devices"] = devices + elif line.startswith("GNS3_MASK_UDEV=") and \ + line.split("=", 1)[1].strip().lower() in ("1", "true", "yes"): + # A privileged systemd-based NOS container (e.g. Cisco XRd) + # runs systemd-udevd, which coldplugs every device it can see + # -- and in privileged mode that includes the HOST's USB/input/ + # audio/disk devices, reconnecting/muting them on every start. + # XRd doesn't need udev (interfaces are pre-created by GNS3), so + # bind /dev/null over the udev units to keep it from running. + for unit in self._UDEV_UNITS: + params["HostConfig"]["Mounts"].append({ + "Type": "bind", + "Source": "/dev/null", + "Target": f"/etc/systemd/system/{unit}", + "ReadOnly": True, + }) + elif line.startswith("GNS3_MASK_SYSTEMD="): + # Generic form: comma/semicolon-separated unit names to mask + # the same way (bind /dev/null over /etc/systemd/system/). + for unit in line.split("=", 1)[1].replace(";", ",").split(","): + unit = unit.strip() + if unit and "/" not in unit and ".." not in unit: + params["HostConfig"]["Mounts"].append({ + "Type": "bind", + "Source": "/dev/null", + "Target": f"/etc/systemd/system/{unit}", + "ReadOnly": True, + }) if params["Entrypoint"] is None: params["Entrypoint"] = [] diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 8041b623a..b71c9e3ef 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -308,6 +308,29 @@ async def test_create_applies_env_host_config(compute_project, manager): ), "GNS3_ user vars must not leak into the container environment" +@pytest.mark.asyncio +async def test_create_masks_systemd_units(compute_project, manager): + """ + GNS3_MASK_UDEV=1 binds /dev/null over the udev units, and GNS3_MASK_SYSTEMD + does the same for arbitrary units -- stopping a privileged systemd container + from udev-coldplugging host devices. + """ + + environment = "GNS3_MASK_UDEV=1\nGNS3_MASK_SYSTEMD=foo.service,bar.socket" + response = {"Id": "e90e34656806", "Warnings": []} + + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: + vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu", environment=environment) + await vm.create() + masked = {m["Target"] for m in mock.call_args[1]["data"]["HostConfig"]["Mounts"] + if m.get("Source") == "/dev/null"} + for unit in DockerVM._UDEV_UNITS: + assert f"/etc/systemd/system/{unit}" in masked + assert "/etc/systemd/system/foo.service" in masked + assert "/etc/systemd/system/bar.socket" in masked + + @pytest.mark.asyncio async def test_create_with_extra_configs(compute_project, manager): """