From c2f68480062bdf7b44f36482ea0a89ca89d80826 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 20 Aug 2026 11:05:39 +0100 Subject: [PATCH] Keep Docker-in-LXC working when commands are enabled after install lxc-attach into an unprivileged guest writes /proc//uid_map, which needs CAP_SETUID in the parent user namespace. NoNewPrivileges drops CAP_SETUID from the effective set and also stops lxc-attach falling back to the setuid newuidmap/newgidmap helpers, so the socket probe dies with "write_id_mapping: 61 Operation not permitted". install.sh already relaxed NoNewPrivileges for this, but only when the agent was installed with --enable-commands. Command execution is also togglable from the server afterwards: applyRemoteConfig starts the command client without rewriting the unit. An agent installed without the flag and switched on later therefore ends up able to run commands and unable to attach to unprivileged guests, so Docker inside every unprivileged LXC disappears from the Proxmox page. The probe failure is logged at debug level and retried on every poll, so the surface looks empty rather than broken while the agent re-probes the whole guest list. Grant CAP_SETUID/CAP_SETGID to any PVE agent rather than gating on the install-time flag, so the later toggle lands on a unit that can attach. Ambient capabilities restore exactly the privilege lxc-attach needs and leave the rest of the sandbox intact; the existing install-time relaxation is unchanged. Verified on a live PVE node. With the hardened unit the probe succeeded only on the three privileged guests and failed on every unprivileged one. After the ambient grant CapEff regained CAP_SETUID and both unprivileged Docker guests were discovered, taking that node from one Docker LXC to three. --- .../v6/internal/subsystems/agent-lifecycle.md | 12 +++++++ .../subsystems/deployment-installability.md | 14 ++++++++ scripts/install.sh | 32 ++++++++++++++++-- scripts/installtests/install_sh_test.go | 33 ++++++++++++++++++- 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 473ce1f48..af63724e5 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1983,6 +1983,18 @@ the intentionally sparse public response. `NoNewPrivileges=false` and `RestrictSUIDSGID=false`. That exception is limited to the PVE command-agent path; it must not become the default for Docker / Podman, host-only, PBS-only, or ordinary non-command agents. + The narrower `AmbientCapabilities=CAP_SETUID CAP_SETGID` grant is not bound + to the install-time flag and must be written for every `--enable-proxmox` + agent of type `pve` or `all`, with or without `--enable-commands`. Command + execution is also enabled from the server at runtime, where + `applyRemoteConfig` starts the command client without regenerating the unit, + so an agent provisioned only for the install-time case would accept commands + it cannot use: `lxc-attach` needs `CAP_SETUID` to write `/proc//uid_map` + for unprivileged guests, and without it the Docker socket probe fails with + `write_id_mapping: 61 Operation not permitted` for every unprivileged LXC. + Because that probe failure is debug-level and retried each poll, the + resulting surface is silently incomplete rather than visibly broken, so the + capability must be provisioned up front rather than diagnosed later. Persistence-sensitive NAS targets must keep one canonical continuity model here: installer-owned bootstraps may use flash-backed or immutable-root launch hooks only as thin trampolines, while the durable wrapper, state, and reboot-surviving binary copy stay in the governed persistent state directory that updater continuity also refreshes. Unix `--update` re-entry must also preserve lifecycle identity for legacy v5.1.x agents that do not yet have v6 `connection.env` state. When a diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index b8f910684..2fa7661ba 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -467,6 +467,20 @@ upgrade, update, release, or artifact-selection behavior. `RestrictSUIDSGID=false`. That exception is deployment-owned operator truth for the Proxmox LXC Docker inventory path and must not leak into non-PVE or non-command agent installs. + Independently of the install-time command flag, the unit for **any** PVE + agent must grant `AmbientCapabilities=CAP_SETUID CAP_SETGID`. `lxc-attach` + into an unprivileged guest writes `/proc//uid_map`, which requires + `CAP_SETUID` in the parent user namespace; `NoNewPrivileges` removes it from + the effective set and simultaneously blocks the setuid + `newuidmap`/`newgidmap` fallback, so the socket probe fails with + `write_id_mapping: 61 Operation not permitted`. Gating this grant on the + install-time flag is not sufficient, because command execution is also + enabled from the server at runtime (`applyRemoteConfig`) without rewriting + the unit; an agent switched on that way could run commands but never attach + to unprivileged guests, so Docker inside them silently vanished from the + Proxmox page. The grant is deliberately narrower than the + `NoNewPrivileges=false` exception above: it restores only the privilege + `lxc-attach` needs and leaves the rest of the sandbox intact. ## Extension Points diff --git a/scripts/install.sh b/scripts/install.sh index 2d7a11fff..ef2e8093f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1012,10 +1012,27 @@ render_systemd_agent_unit() { if [[ -n "$log_target" ]]; then log_lines=$'\n'"StandardOutput=append:${log_target}"$'\n'"StandardError=append:${log_target}" fi + local ambient_line="" if systemd_agent_requires_lxc_attach; then no_new_privileges="false" restrict_suidsgid="false" fi + if systemd_agent_may_attach_lxc; then + # lxc-attach into an unprivileged guest writes /proc//uid_map, + # which needs CAP_SETUID in the parent user namespace. NoNewPrivileges + # drops CAP_SETUID from the effective set and also stops lxc-attach + # falling back to the setuid newuidmap/newgidmap helpers, so the probe + # dies with "write_id_mapping: Operation not permitted" and Docker in + # every unprivileged LXC stays invisible. + # + # This is granted for any PVE agent, not only one installed with + # --enable-commands, because command execution can be turned on later + # from the server (applyRemoteConfig) without rewriting this unit. That + # path used to leave the agent able to run commands but unable to + # attach to unprivileged guests, and the failure is only visible at + # debug level. + ambient_line=$'\n'"AmbientCapabilities=CAP_SETUID CAP_SETGID" + fi local hardening_lines hardening_lines="NoNewPrivileges=${no_new_privileges} @@ -1025,7 +1042,7 @@ ProtectKernelModules=true ProtectControlGroups=true LockPersonality=true RestrictSUIDSGID=${restrict_suidsgid} -SystemCallArchitectures=native" +SystemCallArchitectures=native${ambient_line}" if [[ -d /usr/syno ]]; then # Synology DSM ships a heavily patched, old systemd whose kernels # cannot apply these sandbox directives; NoNewPrivileges alone kills @@ -1053,7 +1070,18 @@ EOF } systemd_agent_requires_lxc_attach() { - if [[ "$ENABLE_COMMANDS" != "true" || "$ENABLE_PROXMOX" != "true" ]]; then + if [[ "$ENABLE_COMMANDS" != "true" ]]; then + return 1 + fi + systemd_agent_may_attach_lxc +} + +# Whether this host could ever need lxc-attach, independent of whether command +# execution was enabled at install time. Command execution is also togglable +# from the server after the fact, so the unit has to be provisioned for it up +# front or the later toggle produces a half-working agent. +systemd_agent_may_attach_lxc() { + if [[ "$ENABLE_PROXMOX" != "true" ]]; then return 1 fi case "${PROXMOX_TYPE:-}" in diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 67179b6a7..db45a34f6 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -266,7 +266,7 @@ func TestInstallSHAllowsProxmoxCommandAgentLXCAttach(t *testing.T) { script := string(content) required := []string{ `systemd_agent_requires_lxc_attach() {`, - `if [[ "$ENABLE_COMMANDS" != "true" || "$ENABLE_PROXMOX" != "true" ]]; then`, + `if [[ "$ENABLE_COMMANDS" != "true" ]]; then`, `""|pve|all)`, `no_new_privileges="false"`, `restrict_suidsgid="false"`, @@ -278,6 +278,37 @@ func TestInstallSHAllowsProxmoxCommandAgentLXCAttach(t *testing.T) { } } +// A PVE agent installed without --enable-commands must still be provisioned for +// lxc-attach, because command execution can be enabled later from the server +// without rewriting the unit. Without CAP_SETUID, lxc-attach cannot write +// /proc//uid_map for unprivileged guests, so Docker inside every +// unprivileged LXC silently disappears from the Proxmox page. +func TestInstallSHGrantsLXCAttachCapabilitiesForAnyPVEAgent(t *testing.T) { + content, err := os.ReadFile(repoFile("scripts", "install.sh")) + if err != nil { + t.Fatalf("read install.sh: %v", err) + } + + script := string(content) + required := []string{ + `systemd_agent_may_attach_lxc() {`, + `if [[ "$ENABLE_PROXMOX" != "true" ]]; then`, + `if systemd_agent_may_attach_lxc; then`, + `AmbientCapabilities=CAP_SETUID CAP_SETGID`, + `SystemCallArchitectures=native${ambient_line}`, + } + for _, needle := range required { + if !strings.Contains(script, needle) { + t.Fatalf("install.sh missing unprivileged-LXC attach capability grant: %s", needle) + } + } + + if strings.Contains(script, `systemd_agent_may_attach_lxc`) && + !strings.Contains(script, "command execution can be turned on later") { + t.Fatal("install.sh must document why the capability grant is not gated on ENABLE_COMMANDS") + } +} + func TestInstallSHPreflightChecksAgentDownloadArtifact(t *testing.T) { var requestedDownloadPath string var requestedDownloadArch string