fix: run _fix_permissions container-side on /gns3volumes mount targets

The host-side pass could not work for unprivileged GNS3 processes: the
.gns3_perms marker is created root-owned by the container-side touch, and
chowning root-owned files from the host requires root.

Rewrite VendorDockerVM._fix_permissions to run the busybox
record/chmod/chown script inside the container (as root) on the
/gns3volumes bind-mount targets — they exist for the container's whole
lifetime and do not depend on the mount --bind bridge, so a container
restart can no longer make the fix hit the overlay copy. A
stopped/exited container is skipped (logged) instead of restarted; the
next start's pass fixes ownership.
This commit is contained in:
YueGuobin 2026-08-12 23:06:34 +08:00
parent 2f36471a55
commit 3455da7da3
No known key found for this signature in database
2 changed files with 78 additions and 80 deletions

View File

@ -223,27 +223,30 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m
- restores the permissions recorded in `.gns3_perms` at the previous stop
(best-effort).
2. **Host-side `_fix_permissions()` override**`DockerVM._fix_permissions`
is container-side (busybox via `docker exec`) and restarts an exited
container just to chown; after a restart the `mount --bind` bridge is gone,
so it would fix the overlay copy and not the host files. The override
instead walks the host-side directories under the node's project directory
directly (they *are* the Docker bind-mount sources), records
`mode:uid:gid:path` into `.gns3_perms` and chowns to the GNS3 user —
no running container required, no restart. It runs both at start (so the
controller can read project files while the node runs) and at stop.
2. **Container-side `_fix_permissions()` override targeting `/gns3volumes`**
`DockerVM._fix_permissions` operates on the in-container paths
(`/etc/opt/srlinux`, …), which only resolve to persistent storage while
the `mount --bind` bridge is up; after a container restart the bridge is
gone and it would chown the overlay copy instead of the host files. It
also restarts an exited container just to chown. The override instead
runs the same busybox record/chmod/chown script **inside the container
(as root) on the `/gns3volumes<path>` paths** — the Docker bind-mount
targets, which exist for the whole container lifetime and need no bridge.
A stopped/exited container is **not** restarted: the pass is skipped and
the next start fixes ownership. It runs at start (so the controller can
read project files while the node runs) and at stop (for files written
during runtime).
> Rootful-Docker assumption: the `.gns3_perms` uid/gid values are recorded from
> the host's view. With rootful Docker (no userns remap) in-container and host
> ids coincide, so restore semantics are identical to init.sh's. This would
> need revisiting for userns-remapped daemons.
> The fix must run container-side: files written by the container are
> host-side root-owned, and an unprivileged GNS3 process cannot chown them
> from the host. Container-side root (with GNS3's `UsernsMode: host`) can.
### Lifecycle summary
| Phase | Normal Docker node | `VendorDockerVM` + `GNS3_SKIP_INIT` |
|-------|--------------------|--------------------------------------|
| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then host-side chown |
| stop | container-side `_fix_permissions` (restarts an exited container) | host-side `_fix_permissions` (no container needed) |
| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then container-side chown on `/gns3volumes` |
| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on `/gns3volumes` paths (skips dead containers, no restart) |
| volume config | identical `_mount_binds` (host → `/gns3volumes<path>`) | identical |
## Troubleshooting
@ -282,7 +285,7 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m
**8. Controller logs `Permission denied` reading files under the node's
project directory while the node runs**
- Root-written files inside a persistent volume. The host-side
- Root-written files inside a persistent volume. The container-side
`_fix_permissions` pass runs at start (fixes the seeded files) and at stop;
files created by the container *during* runtime become readable after the
next stop.
@ -303,8 +306,9 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m
3. **Prototype knobs.** `GNS3_SKIP_INIT` / `GNS3_INTERFACE_NAMES` /
`GNS3_CONSOLE_CMD` are environment-driven; they are not yet first-class node
schema fields and are not declared in the appliance (`gns3a`) schema.
4. **Rootful-Docker assumption** for the host-side `.gns3_perms` recording
(see the volume-persistence section).
4. **Rootful-Docker assumption** (`UsernsMode: host`, set for all GNS3
Docker nodes) so the container-side chown acts on the host files' real
uid/gid (see the volume-persistence section).
5. **Post-boot volume bridge.** The bind-mount bridge is established after the
vendor entrypoint has started (init.sh would do it before). A NOS that
strictly requires its persisted files at its very first read may need a
@ -314,7 +318,8 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m
- `gns3server/compute/docker/vendor_docker_vm.py``VendorDockerVM`:
`_start_docker_exec_console`, `_LazyExecTelnetServer`,
`_setup_skip_init_volumes`, host-side `_fix_permissions`, `start()`.
`_setup_skip_init_volumes`, container-side `_fix_permissions` on
`/gns3volumes`, `start()`.
- `gns3server/compute/docker/docker_vm.py``DockerVM` extension hooks
(`_prepare_init_and_interface_env`, `_start_console_server`,
`_get_container_ifname`, `_cleanup_console_resources`).
@ -329,5 +334,6 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m
| Version | Date | Changes |
|---------|------|---------|
| 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + host-side `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. |
| 1.2 | 2026-08-12 | `_fix_permissions` rewritten: container-side (as root) on the `/gns3volumes` bind-mount targets instead of host-side — host-side chown cannot touch root-owned files when GNS3 is unprivileged. Dead containers are skipped instead of restarted. |
| 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. |
| 1.0 | 2026-08-12 | Initial documentation of the `docker_exec` console and vendor NOS knobs. |

View File

@ -30,11 +30,10 @@ import asyncio
import json
import logging
import os
import stat
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.docker_error import DockerError
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
log = logging.getLogger(__name__)
@ -130,71 +129,64 @@ class VendorDockerVM(DockerVM):
async def _fix_permissions(self):
"""
Host-side override of DockerVM._fix_permissions for SKIP_INIT
containers. The persistent volumes are Docker bind mounts of
directories under the node's project directory, so ownership is fixed
directly on the host no docker exec, no container restart required
(the base implementation restarts an exited container just to chown,
which is wasteful for vendor NOS images).
Container-side override of DockerVM._fix_permissions for vendor NOS
containers. It targets the Docker bind-mount paths
(`/gns3volumes<volume>`) directly instead of the in-container paths:
the in-container paths only resolve to persistent storage while the
`mount --bind` bridge from _setup_skip_init_volumes is up, and after a
container restart the bridge is gone the base implementation would
then chown the overlay copy instead of the host files.
Two passes per volume, mirroring the base/busybox behaviour:
The busybox script runs inside the container as root (a host-side
GNS3 process may be unprivileged and cannot chown root-owned files).
1. record each entry's container-visible mode/uid/gid into
`.gns3_perms` (same `mode:uid:gid:path` format init.sh consumes,
paths are in-container absolute so the restore inside the
container resolves them);
2. chmod u+rX + chown to the host user so the GNS3 process can read
and delete files from the project directory.
Unlike the base implementation, a stopped/exited container is NOT
restarted just to fix permissions (vendor NOS images are heavy to
boot): the pass is skipped and the next start fixes ownership.
"""
try:
state = await self._get_container_state()
except DockerHttp404Error:
log.warning("Container '%s' does not exist, skipping permission fix", self._name)
return
if state == "stopped" or state == "exited":
log.info(
"Container '%s' is %s, skipping permission fix (next start will fix)",
self._name, state,
)
return
uid, gid = os.getuid(), os.getgid()
for volume in self._volumes:
path = os.path.join(self.working_dir, os.path.relpath(volume, "/"))
if not os.path.isdir(path):
continue
def onerror(exc):
log.debug("Could not walk '%s' for container '%s': %s", exc.filename, self._name, exc)
# 1. record container-visible permissions for restore at next start
target = f"/gns3volumes{volume}"
log.debug("Docker container '%s' fix ownership on %s", self._name, target)
try:
with open(os.path.join(path, ".gns3_perms"), "w") as perms_file:
for root, dirs, files in os.walk(path, onerror=onerror):
for entry in dirs + files:
entry_path = os.path.join(root, entry)
try:
st = os.lstat(entry_path)
except OSError:
continue
container_path = os.path.join(volume, os.path.relpath(entry_path, path))
perms_file.write(
f"{stat.S_IMODE(st.st_mode):o}:{st.st_uid}:{st.st_gid}:{container_path}\n"
)
except OSError as e:
log.warning(
"Could not record permissions for '%s' on container '%s': %s", path, self._name, e
process = await asyncio.subprocess.create_subprocess_exec(
"docker",
"exec",
self._cid,
"/gns3/bin/busybox",
"sh",
"-c",
"("
f'/gns3/bin/busybox find "{target}" -depth -print0'
f" | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c '%a:%u:%g:%n' > \"{target}/.gns3_perms\""
")"
f' && /gns3/bin/busybox chmod -R u+rX "{target}"'
f' && /gns3/bin/busybox chown {uid}:{gid} -R "{target}"',
stderr=asyncio.subprocess.PIPE,
)
continue
# 2. chmod u+rX + chown to the host user
for root, dirs, files in os.walk(path, onerror=onerror):
for entry in dirs + files:
entry_path = os.path.join(root, entry)
try:
st = os.lstat(entry_path)
is_link = stat.S_ISLNK(st.st_mode)
if not is_link:
mode = stat.S_IMODE(st.st_mode)
new_mode = mode | 0o400 # u+r
if stat.S_ISDIR(st.st_mode) or (mode & 0o111): # u+X
new_mode |= 0o100
os.chmod(entry_path, new_mode)
os.lchown(entry_path, uid, gid)
except OSError as e:
log.debug(
"Could not fix permissions on '%s' for container '%s': %s",
entry_path, self._name, e,
)
self._permissions_fixed = True
except OSError as e:
raise DockerError(f"Could not fix permissions for {volume}: {e}")
await process.wait()
if process.returncode != 0:
stderr = (await process.stderr.read()).decode(errors="replace").strip()
log.error(
"Failed to fix permissions on '%s' for container '%s': %s",
volume, self._name, stderr or f"exit code {process.returncode}",
)
else:
self._permissions_fixed = True
async def _setup_skip_init_volumes(self):
"""