From 66c621fe7a92bc75ebb1d8df68d1669fdda9df3c Mon Sep 17 00:00:00 2001 From: MacRimi Date: Thu, 8 Oct 2026 22:57:37 +0200 Subject: [PATCH] fix(oci): the access rule covers the container's root, and a shared volume is writable by its group --- lang/de.json | 2 +- lang/es.json | 1 + lang/fr.json | 2 +- lang/it.json | 4 +-- lang/pt.json | 2 +- lang/sk.json | 2 +- lang/sv.json | 2 +- oci/catalog/apps/sharecovex.json | 1 + oci/catalog/curated/sharecovex.json | 1 + oci/remote/install_oci.sh | 39 +++++++++++++++++++++------- oci/src/proxmenux_oci/installer.py | 5 ++-- oci/tests/test_shared_directories.py | 13 +++++++++- 12 files changed, 54 insertions(+), 20 deletions(-) diff --git a/lang/de.json b/lang/de.json index c5dc19f9..cd6774e0 100644 --- a/lang/de.json +++ b/lang/de.json @@ -190,7 +190,7 @@ "Access bridge for Nextcloud": "Access Bridge für Nextcloud", "Access bridge for Paperless": "Access Bridge für Paperless", "Access bridge for Tandoor": "Access Bridge für Tandoor", - "Access could not be granted; the application cannot write to:": "Der Zugriff konnte nicht gewährt werden;Die Anwendung kann nicht schreiben an:", + "Access could not be granted; the application cannot write to:": "Der Zugriff konnte nicht gewährt werden; die Anwendung kann nicht schreiben an:", "Access granted to the application on:": "Zugriff auf die Anwendung gewährt am:", "Access profile:": "Zugangsprofil:", "Access token of the Jupyter Lab web interface": "Access-Token des Jupyter Lab Webinterfaces", diff --git a/lang/es.json b/lang/es.json index 34c7599f..6487bebb 100644 --- a/lang/es.json +++ b/lang/es.json @@ -767,6 +767,7 @@ "Changing between a privileged and an unprivileged container, or the host monitor mode, requires installing the application again": "Cambiar entre contenedor privilegiado y sin privilegios, o el modo de monitor del host, requiere instalar la aplicación de nuevo", "Changing the machine type on an existing installed VM is not safe: it changes the chipset and PCI slot layout, which typically prevents the guest OS from booting.": "Cambiar el tipo de máquina de una VM ya instalada no es seguro: cambia el chipset y la disposición de las ranuras PCI, lo que normalmente impide que arranque el sistema operativo de la VM.", "Changing the rootfs or its storage requires a separate migration": "Cambiar los rootfs o su almacenamiento requiere una migración separada", + "Changing the shared identity (1000:1000) in Settings of the panel means giving the new one access on the host again: the volumes and the access rules are prepared for 1000:1000.": "Cambiar la identidad compartida (1000:1000) en Ajustes del panel obliga a dar acceso de nuevo a la nueva en el host: los volúmenes y las reglas de acceso se preparan para 1000:1000.", "Changing the storage or size of a disk requires a migration; empty disks are not created": "Cambiar el almacenamiento o el tamaño de un disco requiere una migración; los discos vacíos no se crean", "Check": "Controlar", "Check BIOS/UEFI in Hardware > BIOS — must match what the original VM used": "Verifique BIOS/UEFI en Hardware > BIOS: debe coincidir con lo que usó la VM original", diff --git a/lang/fr.json b/lang/fr.json index 818d3f11..fdd84cba 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -190,7 +190,7 @@ "Access bridge for Nextcloud": "Pont d'accès pour Nextcloud", "Access bridge for Paperless": "Pont d'accès pour sans papier", "Access bridge for Tandoor": "Pont d'accès pour Tandoor", - "Access could not be granted; the application cannot write to:": "L'accès n'a pas pu être accordé ;l'application ne peut pas écrire dans :", + "Access could not be granted; the application cannot write to:": "L'accès n'a pas pu être accordé ; l'application ne peut pas écrire dans :", "Access granted to the application on:": "Accès accordé à l’application sur :", "Access profile:": "Profil d'accès :", "Access token of the Jupyter Lab web interface": "Jeton d'accès de l'interface web Jupyter Lab", diff --git a/lang/it.json b/lang/it.json index 0d2a55db..87a5f132 100644 --- a/lang/it.json +++ b/lang/it.json @@ -190,7 +190,7 @@ "Access bridge for Nextcloud": "Bridge di accesso per Nextcloud", "Access bridge for Paperless": "Bridge di accesso per Paperless", "Access bridge for Tandoor": "Bridge di accesso per Tandoor", - "Access could not be granted; the application cannot write to:": "impossibile concedere l'accesso;l'applicazione non può scrivere a:", + "Access could not be granted; the application cannot write to:": "Impossibile concedere l'accesso; l'applicazione non può scrivere a:", "Access granted to the application on:": "Accesso concesso all'applicazione su:", "Access profile:": "Profilo di accesso:", "Access token of the Jupyter Lab web interface": "Token di accesso all'interfaccia web di Jupyter Lab", @@ -6122,7 +6122,7 @@ "The Valkey volume needs at least 1 GB": "Il volume Valkey richiede almeno 1 GB", "The acceleration evidence differs from the verified inventory": "I dati sull'accelerazione sono diversi dall'inventario verificato", "The acceleration profile does not support this architecture:": "Il profilo di accelerazione non supporta questa architettura:", - "The access rule added to this host directory is kept:": "la regola di accesso aggiunta a questa directory host viene mantenuta:", + "The access rule added to this host directory is kept:": "La regola di accesso aggiunta a questa directory host viene mantenuta:", "The active kernel driver is not vfio-pci, but the entry in": "Il driver del kernel attivo non è vfio-pci, ma la voce in", "The active kernel driver is not vfio-pci, but the entry will rebind the GPU to vfio-pci on the next reboot, breaking the LXC passthrough about to be configured.": "Il driver del kernel attivo non è vfio-pci, ma la voce ricollegherà la GPU a vfio-pci al prossimo riavvio, interrompendo il passthrough LXC che sta per essere configurato.", "The active kernel driver is not vfio-pci, but the entry will rebind the GPU to vfio-pci on the next reboot.": "Il driver del kernel attivo non è vfio-pci, ma la voce ricollegherà la GPU a vfio-pci al prossimo riavvio.", diff --git a/lang/pt.json b/lang/pt.json index 96dd7b20..819a011f 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -190,7 +190,7 @@ "Access bridge for Nextcloud": "Ponte de acesso para Nextcloud", "Access bridge for Paperless": "Ponte de acesso para Paperless", "Access bridge for Tandoor": "Ponte de acesso para Tandoor", - "Access could not be granted; the application cannot write to:": "O acesso não pôde ser concedido;o aplicativo não pode gravar em:", + "Access could not be granted; the application cannot write to:": "O acesso não pôde ser concedido; o aplicativo não pode gravar em:", "Access granted to the application on:": "Acesso concedido ao aplicativo em:", "Access profile:": "Perfil de acesso:", "Access token of the Jupyter Lab web interface": "Token de acesso da interface web Jupyter Lab", diff --git a/lang/sk.json b/lang/sk.json index ad9e3d61..f674d3ac 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -190,7 +190,7 @@ "Access bridge for Nextcloud": "Prístupový mostík pre Nextcloud", "Access bridge for Paperless": "Prístupový mostík pre bezpapierové", "Access bridge for Tandoor": "Prístupový mostík pre Tandoor", - "Access could not be granted; the application cannot write to:": "Prístup nebolo možné udeliť;aplikácia nemôže písať na:", + "Access could not be granted; the application cannot write to:": "Prístup nebolo možné udeliť; aplikácia nemôže písať na:", "Access granted to the application on:": "Prístup udelený aplikácii dňa:", "Access profile:": "Profil prístupu:", "Access token of the Jupyter Lab web interface": "Prístupový token webového rozhrania Jupyter Lab", diff --git a/lang/sv.json b/lang/sv.json index f9a8ac19..23fc4a02 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -190,7 +190,7 @@ "Access bridge for Nextcloud": "Tillgångsbro för Nextcloud", "Access bridge for Paperless": "Tillgångsbro för papperslös", "Access bridge for Tandoor": "Tillgångsbro för Tandoor", - "Access could not be granted; the application cannot write to:": "Åtkomst kunde inte beviljas;ansökan kan inte skriva till:", + "Access could not be granted; the application cannot write to:": "Åtkomst kunde inte beviljas; ansökan kan inte skriva till:", "Access granted to the application on:": "Tillgång beviljad till ansökan på:", "Access profile:": "Åtkomstprofil:", "Access token of the Jupyter Lab web interface": "Tillgångstoken av Jupyter Labs webbgränssnitt", diff --git a/oci/catalog/apps/sharecovex.json b/oci/catalog/apps/sharecovex.json index 2726fd49..736991e4 100644 --- a/oci/catalog/apps/sharecovex.json +++ b/oci/catalog/apps/sharecovex.json @@ -35,6 +35,7 @@ "tips": [ "Each directory to share is mounted under /shares with the name given to it during the installation; more can be added later with Modify.", "A directory of the host keeps the owner it already has. The panel shows whether the shared identity (1000:1000 by default) can write to it.", + "Changing the shared identity (1000:1000) in Settings of the panel means giving the new one access on the host again: the volumes and the access rules are prepared for 1000:1000.", "Create the Samba users in Settings of the panel, then publish each directory from Resources.", "NFS is served as NFSv3. NFSv4 needs the privileged profile offered by the advanced installation.", "/config: Proxmox volume included in backup and kept when the application is updated or modified." diff --git a/oci/catalog/curated/sharecovex.json b/oci/catalog/curated/sharecovex.json index 2726fd49..736991e4 100644 --- a/oci/catalog/curated/sharecovex.json +++ b/oci/catalog/curated/sharecovex.json @@ -35,6 +35,7 @@ "tips": [ "Each directory to share is mounted under /shares with the name given to it during the installation; more can be added later with Modify.", "A directory of the host keeps the owner it already has. The panel shows whether the shared identity (1000:1000 by default) can write to it.", + "Changing the shared identity (1000:1000) in Settings of the panel means giving the new one access on the host again: the volumes and the access rules are prepared for 1000:1000.", "Create the Samba users in Settings of the panel, then publish each directory from Resources.", "NFS is served as NFSv3. NFSv4 needs the privileged profile offered by the advanced installation.", "/config: Proxmox volume included in backup and kept when the application is updated or modified." diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 6a432911..d25216de 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -1093,7 +1093,7 @@ apply_installer_profile() { local generated_count preparation_count tls_count seeded_volumes mounted=0 failed=0 local rootfs="/var/lib/lxc/${VMID}/rootfs" local encoded item path mode owner destination target remove_lost_found owner_strategy - local only_when_mount_type selected_mount_type + local only_when_mount_type selected_mount_type preparation_mode local entrypoint working_directory halt_signal command_json compose_entrypoint_json user_spec local supplemental_groups tls_config cert_path key_path cert_destination key_destination local tls_directory valid_days common_name san @@ -1198,6 +1198,13 @@ apply_installer_profile() { *) oci_log "Unsupported owner strategy: $owner_strategy"; failed=1 ;; esac (( failed == 0 )) || break + # A mode the preparation states, for a directory several users write to. + preparation_mode=$(jq -r '.mode // empty' <<<"$item") + if [[ -n $preparation_mode ]]; then + [[ $preparation_mode =~ ^[0-7]{3,4}$ ]] \ + || { oci_log "Invalid volume preparation mode: $preparation_mode"; failed=1; break; } + chmod "$preparation_mode" "${rootfs}${target}" || { failed=1; break; } + fi oci_log "Volume prepared before the first start: $target" volumes_prepared=$((volumes_prepared + 1)) done < <(jq -r '.proxmox.installer_profile.volume_preparations[]? | @base64' "$TEMPLATE_FILE") @@ -1779,21 +1786,33 @@ fi if [[ $SELINUX_LABEL_DISABLED == true ]]; then oci_log "label:disable kept as metadata; Proxmox uses AppArmor, not SELinux, for this LXC" fi -# Gives the identity the application writes with access to a host directory -# that belongs to someone else, with an ACL: the owner and the permissions of -# what is there stay as they are, and the owner of the directory keeps access -# to what the application creates in it. +# Gives the application access to a host directory that belongs to someone +# else, with an ACL: the owner and the permissions of what is there stay as +# they are, and the owner of the directory keeps access to what the +# application creates in it. The rule names the identity the application +# writes with and, in an unprivileged container, its root user as well, which +# is who lists the folders and answers the access checks of an NFS client. grant_shared_access() { - local source=$1 owner group triplet default_spec - command -v setfacl >/dev/null 2>&1 && command -v getfacl >/dev/null 2>&1 || return 1 + local source=$1 owner group triplet spec default_spec + if ! command -v setfacl >/dev/null 2>&1 || ! command -v getfacl >/dev/null 2>&1; then + # A host installed on ZFS does not bring the ACL tools. + oci_log "Installing the acl package to grant access to $source" + DEBIAN_FRONTEND=noninteractive apt-get install -y acl >>"${OCI_LOG:-/dev/null}" 2>&1 || return 1 + fi + spec="u:${HOST_BIND_UID}:rwX,g:${HOST_BIND_GID}:rwX" + default_spec="d:u:${HOST_BIND_UID}:rwx,d:g:${HOST_BIND_GID}:rwx" + if [[ $HOST_ROOT_UID != 0 ]]; then + spec="${spec},u:${HOST_ROOT_UID}:rwX,g:${HOST_ROOT_GID}:rwX" + default_spec="${default_spec},d:u:${HOST_ROOT_UID}:rwx,d:g:${HOST_ROOT_GID}:rwx" + fi # Granted already: an update or a modification installs the container again. if getfacl -cpn "$source" 2>/dev/null | grep -q "^user:${HOST_BIND_UID}:rwx" \ - && getfacl -cpn "$source" 2>/dev/null | grep -q "^default:user:${HOST_BIND_UID}:rwx"; then + && getfacl -cpn "$source" 2>/dev/null | grep -q "^default:user:${HOST_BIND_UID}:rwx" \ + && { [[ $HOST_ROOT_UID == 0 ]] || getfacl -cpn "$source" 2>/dev/null | grep -q "^user:${HOST_ROOT_UID}:rwx"; }; then return 0 fi owner=$(stat -c '%u' "$source") || return 1 group=$(stat -c '%g' "$source") || return 1 - default_spec="d:u:${HOST_BIND_UID}:rwx,d:g:${HOST_BIND_GID}:rwx" if [[ $owner != 0 && $owner != "$HOST_BIND_UID" ]]; then default_spec="${default_spec},d:u:${owner}:rwx" fi @@ -1804,7 +1823,7 @@ grant_shared_access() { triplet=${triplet//S/-} default_spec="${default_spec},d:g:${group}:${triplet}" fi - setfacl -R -P -m "u:${HOST_BIND_UID}:rwX,g:${HOST_BIND_GID}:rwX" "$source" 2>>"${OCI_LOG:-/dev/null}" || return 1 + setfacl -R -P -m "$spec" "$source" 2>>"${OCI_LOG:-/dev/null}" || return 1 find "$source" -xdev -type d -exec setfacl -m "$default_spec" {} + 2>>"${OCI_LOG:-/dev/null}" || return 1 } diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index 9ca9aa46..1b5e87ae 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -176,9 +176,10 @@ def ask_shared_directories(ui, template: dict[str, Any], mounts: list[dict[str, if advanced: mount["source"] = ask_storage(ui, f"{translate('Storage for')} {target}", "rootdir", storage) mount["size_gb"] = _ask_size(ui, target, int(declared.get("default_size_gb", 32))) - # The application writes to it as its own user, not as root. + # The application writes to it as its own user, not as root, and + # its group writes too: what is created inside keeps that group. preparation = {"container_path": target, "remove_lost_found": True, - "owner_strategy": "mapped-application-user", + "owner_strategy": "mapped-application-user", "mode": "2775", "only_when_mount_type": "managed-volume"} if preparation not in profile.setdefault("volume_preparations", []): profile["volume_preparations"].append(preparation) diff --git a/oci/tests/test_shared_directories.py b/oci/tests/test_shared_directories.py index 34d2fdc7..163452ba 100644 --- a/oci/tests/test_shared_directories.py +++ b/oci/tests/test_shared_directories.py @@ -92,6 +92,10 @@ class SharedDirectoriesTests(unittest.TestCase): prepared = {item["container_path"]: item["owner_strategy"] for item in template["proxmox"]["installer_profile"]["volume_preparations"]} self.assertEqual(prepared, {"/config": "mapped-root", "/shares/backups": "mapped-application-user"}) + # Its group writes as well, for when each Samba user acts as itself. + modes = {item["container_path"]: item.get("mode") + for item in template["proxmox"]["installer_profile"]["volume_preparations"]} + self.assertEqual(modes, {"/config": None, "/shares/backups": "2775"}) def test_a_default_installation_stays_unprivileged_and_generates_the_panel_password(self, *_): ui, _template, plan = self.build({NAME: ["media"]}) @@ -194,7 +198,14 @@ class SharedDirectoriesTests(unittest.TestCase): self.assertNotIn("chown", grant) self.assertNotIn("chmod", grant) self.assertIn('grep -q "^default:user:${HOST_BIND_UID}:rwx"', grant) - self.assertIn('setfacl -R -P -m "u:${HOST_BIND_UID}:rwX,g:${HOST_BIND_GID}:rwX"', grant) + self.assertIn('spec="u:${HOST_BIND_UID}:rwX,g:${HOST_BIND_GID}:rwX"', grant) + self.assertIn('setfacl -R -P -m "$spec" "$source"', grant) + # In an unprivileged container its root user is named too: it lists the folders. + self.assertIn('spec="${spec},u:${HOST_ROOT_UID}:rwX,g:${HOST_ROOT_GID}:rwX"', grant) + self.assertIn("apt-get install -y acl", grant) + # A preparation may state the mode of its directory. + self.assertIn('chmod "$preparation_mode" "${rootfs}${target}"', script) + self.assertIn("[[ $preparation_mode =~ ^[0-7]{3,4}$ ]]", script) def test_a_name_that_cannot_identify_a_share_is_refused(self, *_): for name in ("Media", "my files", "9lives", "config", "", "a" * 33):