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.
This commit is contained in:
YueGuobin 2026-08-14 01:12:21 +08:00
parent 088f1da77a
commit 347537f1b3
No known key found for this signature in database
2 changed files with 61 additions and 0 deletions

View File

@ -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/<unit>).
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"] = []

View File

@ -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):
"""