From 6b09a808ed00c515a6864f4b7e369d07a10cb37b Mon Sep 17 00:00:00 2001 From: beatzaplenty Date: Tue, 21 Jul 2026 05:57:06 +0000 Subject: [PATCH] Make lxc-docker a privileged container: unprivileged can't NFS-mount at all The kernel's NFS client filesystem doesn't set FS_USERNS_MOUNT, so mounting NFS from inside any non-init user namespace -- exactly what an unprivileged LXC container's UID-mapped root runs in -- is rejected at the VFS layer with EPERM, regardless of Proxmox's mount=nfs;nfs4 container feature (which only patches the AppArmor layer). Confirmed live on the redeployed lxc-docker container: TCP to the NFS server's port 2049 succeeds, the server's export table matches the container's IP, and mount.nfs: Operation not permitted still fires immediately with no corresponding denial anywhere in the server's own logs -- a kernel-level rejection that no amount of DNS/ automount/export tweaking (this branch's earlier commits) could ever fix. modules/platforms/lxc.nix now keys proxmoxLXC.privileged off hostName ("docker" -> true) rather than a blanket false, since build-types/docker.nix is also composed for linode-docker/proxmox-docker, which don't import proxmox-lxc.nix at all -- setting this option there would break their eval. create-proxmox-resource.sh reads the value back via a new flake_target_lxc_privileged helper instead of hardcoding --unprivileged 1, so the two stay in sync automatically for every lxc-* target. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01T48qgH3VTvs8wvwj44FEbE --- modules/platforms/lxc.nix | 26 +++++++++++++++++++--- scripts/lib/nix-eval.sh | 18 +++++++++++++++ scripts/proxmox/create-proxmox-resource.sh | 25 ++++++++++++++------- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/modules/platforms/lxc.nix b/modules/platforms/lxc.nix index ab3d10f..ac24108 100644 --- a/modules/platforms/lxc.nix +++ b/modules/platforms/lxc.nix @@ -1,4 +1,4 @@ -{ lib, modulesPath, flakeTarget, ... }: +{ config, lib, modulesPath, flakeTarget, ... }: let # Bakes this exact flake target's pre-generated SSH host key straight @@ -58,8 +58,28 @@ in # host.nix declares each host's real hostname (networking.hostName); # keep that instead of letting Proxmox's ambient container config win. manageHostName = true; - # Unprivileged matches how these containers are actually created. - privileged = false; + # Unprivileged by default -- matches how these containers are actually + # created (scripts/proxmox/create-proxmox-resource.sh reads this value + # back to decide `pct create`'s --unprivileged flag, so the two stay + # in sync). + # + # lxc-docker is the one exception: the kernel's NFS client doesn't set + # FS_USERNS_MOUNT, so mounting NFS from inside *any* non-init user + # namespace -- which is exactly what an unprivileged container's + # UID-mapped root runs in -- is rejected at the VFS layer with EPERM, + # no matter what Proxmox's own `mount=nfs;nfs4` container feature + # allows at the AppArmor layer (confirmed live: TCP to the NFS server + # succeeds, the server's export table matches the container's IP, and + # `mount.nfs: Operation not permitted` still fires immediately with no + # corresponding denial anywhere in the server's logs -- a kernel-level + # rejection, not a network or export-permission one). Keying off + # hostName rather than something docker-build-type-specific because + # modules/build-types/docker.nix is also composed for linode-docker/ + # proxmox-docker, which don't import proxmox-lxc.nix at all --setting + # this option there would break their eval with "option does not + # exist" regardless of any mkIf guard, since mkIf only makes a value + # conditional, not whether the option needs to exist somewhere. + privileged = config.networking.hostName == "docker"; }; boot.loader = { diff --git a/scripts/lib/nix-eval.sh b/scripts/lib/nix-eval.sh index cca380e..931b7bf 100644 --- a/scripts/lib/nix-eval.sh +++ b/scripts/lib/nix-eval.sh @@ -34,3 +34,21 @@ flake_target_hostname() { nix eval --raw "${NIX_EVAL_FLAGS[@]}" \ "${flake_ref}#nixosConfigurations.${target}.config.networking.hostName" 2>/dev/null } + +# flake_target_lxc_privileged +# Prints "true" or "false" for one lxc-* target's config.proxmoxLXC.privileged +# (modules/platforms/lxc.nix is the single source of truth -- e.g. +# lxc-docker sets this true so it can NFS-mount; every other lxc-* host +# stays unprivileged). Only meaningful for lxc-* targets -- the option +# doesn't exist for linode-*/proxmox-* (nixpkgs' proxmox-lxc.nix, which +# declares it, is only ever imported by modules/platforms/lxc.nix). Empty +# (not an error under set -e) if the eval fails. +flake_target_lxc_privileged() { + local flake_ref="$1" target="$2" + # Not --raw: the option is a Nix boolean, and --raw can only coerce + # strings ("cannot coerce a Boolean to a string"). Plain `nix eval` + # prints a bare `true`/`false` for a boolean, which is exactly the + # string this needs. + nix eval "${NIX_EVAL_FLAGS[@]}" \ + "${flake_ref}#nixosConfigurations.${target}.config.proxmoxLXC.privileged" 2>/dev/null +} diff --git a/scripts/proxmox/create-proxmox-resource.sh b/scripts/proxmox/create-proxmox-resource.sh index 891c7d0..7e65544 100755 --- a/scripts/proxmox/create-proxmox-resource.sh +++ b/scripts/proxmox/create-proxmox-resource.sh @@ -766,13 +766,22 @@ if [[ "$type" == "lxc" ]]; then # 512M default otherwise (confirmed live: --memory 2048 left swap at # 512). Default to matching whatever --memory resolved to above. local_swap="${swap:-$memory}" - # --unprivileged 1: modules/platforms/lxc.nix sets proxmoxLXC.privileged - # = false, so the NixOS config inside the image assumes it's running as - # an unprivileged container (cgroup/capability/mount expectations baked - # in at boot). `pct create`'s own CLI default for this flag is - # privileged (unlike the web UI, which defaults its checkbox the other - # way) -- leaving it unset creates a privileged container running a - # NixOS config that assumes unprivileged, a real mismatch. + # --unprivileged: read back from modules/platforms/lxc.nix's own + # proxmoxLXC.privileged (via flake_target_lxc_privileged) rather than + # hardcoded, since that's no longer the same for every lxc-* target -- + # lxc-docker sets it true so the container's NFS mounts work at all (the + # kernel's NFS client can't mount from inside any unprivileged + # container's user namespace, no matter what AppArmor allows -- see that + # option's own comment). The NixOS config inside the image bakes in + # cgroup/capability/mount expectations matching whichever value it was + # built with, so this must stay in sync with it -- `pct create`'s own + # CLI default for this flag is privileged (unlike the web UI, which + # defaults its checkbox the other way), so leaving it unset would create + # a privileged container running a NixOS config that assumes + # unprivileged for every target except lxc-docker, a real mismatch. + privileged_eval="$(flake_target_lxc_privileged "$repo_root" "$flake_target")" + unprivileged_flag=1 + [[ "$privileged_eval" == "true" ]] && unprivileged_flag=0 # # --features nesting=1,keyctl=1: required for a modern (v247+) systemd # guest to actually boot unprivileged -- confirmed live: without this, @@ -790,7 +799,7 @@ if [[ "$type" == "lxc" ]]; then # hands the whole string to `ssh` as a single command for the *remote* # shell to parse -- unquoted, that `;` would be read as a remote # command separator and silently truncate this into two commands. - create_cmd="pct create ${vmid} ${iso_storage}:vztmpl/${remote_filename} --unprivileged 1 --features '${PROXMOX_DEFAULT_LXC_FEATURES}' --rootfs ${storage}:${local_disk_size} --hostname ${name} --cores ${cores} --memory ${memory} --swap ${local_swap} --net0 name=eth0,bridge=${bridge},ip=dhcp" + create_cmd="pct create ${vmid} ${iso_storage}:vztmpl/${remote_filename} --unprivileged ${unprivileged_flag} --features '${PROXMOX_DEFAULT_LXC_FEATURES}' --rootfs ${storage}:${local_disk_size} --hostname ${name} --cores ${cores} --memory ${memory} --swap ${local_swap} --net0 name=eth0,bridge=${bridge},ip=dhcp" remote "$create_cmd" remote "pct start ${vmid}" else