mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
feat(oci): offer access to an existing host directory and gather the file-sharing applications
feat(oci): offer access to a host directory that already exists feat(oci): gather the file-sharing applications in Files & Sharing fix(oci): ShareCoveX keeps its settings in a 1 GB volume, and the new translations are mended
This commit is contained in:
+4
-4
@@ -35,16 +35,16 @@ Questions and ideas about OCI testing go in [discussion #360](https://github.com
|
||||
| AdGuard Home | Adblock & DNS |
|
||||
| Adguardhome Sync | Adblock & DNS |
|
||||
| Alby Hub ✨ | Finance & Budgeting |
|
||||
| Alist | Productivity & Workflows |
|
||||
| Alist | Files & Sharing |
|
||||
| aMule | Files & Downloads |
|
||||
| Chromium | Browsers & Web Desktops |
|
||||
| CodeProject.AI Server | AI |
|
||||
| CopyParty | Files & Downloads |
|
||||
| CopyParty | Files & Sharing |
|
||||
| Crafty | Gaming & Leisure |
|
||||
| Ddclient | Adblock & DNS |
|
||||
| Duplicati | Backup & Recovery |
|
||||
| Etherpad | Documents & Notes |
|
||||
| FileBrowser Quantum | Tools |
|
||||
| FileBrowser Quantum | Files & Sharing |
|
||||
| FlareSolverr | *Arr Suite |
|
||||
| Flexget | *Arr Suite |
|
||||
| Frigate | NVR & Cameras |
|
||||
@@ -58,7 +58,7 @@ Questions and ideas about OCI testing go in [discussion #360](https://github.com
|
||||
| Motioneye | NVR & Cameras |
|
||||
| Nextcloud | Productivity & Workflows |
|
||||
| Nextcloud Stack | Productivity & Workflows |
|
||||
| OpenList | Productivity & Workflows |
|
||||
| OpenList | Files & Sharing |
|
||||
| Openssh Server | Remote Access & VPN |
|
||||
| Paperless-ngx | Productivity & Workflows |
|
||||
| Phpmyadmin | Databases |
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"description": {
|
||||
"en_US": "Official image with managed persistent configuration and selectable content storage. Validated on amd64: clean install, authenticated login, same-digest recreation and interrupted-candidate recovery. Cross-release migration and arm64 runtime not tested."
|
||||
},
|
||||
"category": "tools",
|
||||
"category_label": "Tools",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"author": "gtsteffaniak",
|
||||
"developer": "gtsteffaniak",
|
||||
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/filebrowser-quantum.webp",
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"default": "managed-volume",
|
||||
"managed_volume": {
|
||||
"backup": true,
|
||||
"default_size_gb": 16
|
||||
"default_size_gb": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"adguard-home": "adblock",
|
||||
"adminer": "databases",
|
||||
"airsonic-advanced": "media",
|
||||
"alist": "sharing",
|
||||
"alist-sync": "sharing",
|
||||
"altus": "communication",
|
||||
"ardour": "creative",
|
||||
"audacity": "creative",
|
||||
@@ -53,6 +55,7 @@
|
||||
"cloudbeaver": "databases",
|
||||
"code-server": "ai",
|
||||
"cops": "media",
|
||||
"copyparty": "sharing",
|
||||
"crafty": "gaming",
|
||||
"cura": "creative",
|
||||
"darktable": "creative",
|
||||
@@ -74,6 +77,8 @@
|
||||
"emulatorjs": "gaming",
|
||||
"faster-whisper": "ai",
|
||||
"ferdium": "communication",
|
||||
"filebrowser-quantum": "sharing",
|
||||
"filedrop": "sharing",
|
||||
"filezilla": "downloads",
|
||||
"firefox": "desktop",
|
||||
"flexget": "arr",
|
||||
@@ -126,6 +131,7 @@
|
||||
"ngircd": "communication",
|
||||
"nzbhydra2": "arr",
|
||||
"obsidian": "documents",
|
||||
"openlist": "sharing",
|
||||
"openshot": "creative",
|
||||
"openspeedtest": "monitoring",
|
||||
"openssh-server": "remote",
|
||||
@@ -133,22 +139,25 @@
|
||||
"opera": "desktop",
|
||||
"orcaslicer": "creative",
|
||||
"oscam": "media",
|
||||
"pairdrop": "sharing",
|
||||
"pcsx2": "gaming",
|
||||
"pelorus": "ai",
|
||||
"pidgin": "communication",
|
||||
"pingvin-share": "sharing",
|
||||
"piper": "ai",
|
||||
"piwigo": "media",
|
||||
"playit-agent": "web",
|
||||
"ppsspp": "gaming",
|
||||
"pwndrop": "downloads",
|
||||
"psitransfer": "sharing",
|
||||
"pwndrop": "sharing",
|
||||
"pycharm": "ai",
|
||||
"pydio-cells": "downloads",
|
||||
"pydio-cells": "sharing",
|
||||
"pyload-ng": "downloads",
|
||||
"qdirstat": "tools",
|
||||
"raneto": "documents",
|
||||
"rawtherapee": "creative",
|
||||
"remmina": "remote",
|
||||
"resilio-sync": "downloads",
|
||||
"resilio-sync": "sharing",
|
||||
"retroarch": "gaming",
|
||||
"retroarch-inglebard": "gaming",
|
||||
"rpcs3": "gaming",
|
||||
@@ -162,12 +171,14 @@
|
||||
"sickgear": "arr",
|
||||
"signal": "communication",
|
||||
"smokeping": "monitoring",
|
||||
"snapdrop": "sharing",
|
||||
"speedtest-tracker": "monitoring",
|
||||
"spotube": "media",
|
||||
"sqlitebrowser": "databases",
|
||||
"steam": "gaming",
|
||||
"swag": "web",
|
||||
"synclounge": "media",
|
||||
"syncthing": "sharing",
|
||||
"syslog-ng": "monitoring",
|
||||
"tailscale": "remote",
|
||||
"telegram": "communication",
|
||||
@@ -193,7 +204,7 @@
|
||||
"winegui": "desktop",
|
||||
"wireshark": "network",
|
||||
"wps-office": "documents",
|
||||
"xbackbone": "downloads",
|
||||
"xbackbone": "sharing",
|
||||
"xemu": "gaming",
|
||||
"yaak": "ai",
|
||||
"your_spotify": "media",
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"description": {
|
||||
"en_US": "Official image with managed persistent configuration and selectable content storage. Validated on amd64: clean install, authenticated login, same-digest recreation and interrupted-candidate recovery. Cross-release migration and arm64 runtime not tested."
|
||||
},
|
||||
"category": "tools",
|
||||
"category_label": "Tools",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"author": "gtsteffaniak",
|
||||
"developer": "gtsteffaniak",
|
||||
"icon": null,
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"default": "managed-volume",
|
||||
"managed_volume": {
|
||||
"backup": true,
|
||||
"default_size_gb": 16
|
||||
"default_size_gb": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+30
-30
@@ -269,8 +269,8 @@
|
||||
"pushed_at": "2026-09-11T10:43:22Z",
|
||||
"updated_at": "2024-11-22",
|
||||
"main_image": "xhofe/alist:latest",
|
||||
"category": "productivity",
|
||||
"category_label": "Productivity & Workflows",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/alist.json",
|
||||
"template_status": "laboratory-validated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -305,8 +305,8 @@
|
||||
"pushed_at": "2026-09-11T10:43:22Z",
|
||||
"updated_at": "2025-03-15",
|
||||
"main_image": "xjxjin/alist-sync:latest",
|
||||
"category": "productivity",
|
||||
"category_label": "Productivity & Workflows",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/alist-sync.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -1680,8 +1680,8 @@
|
||||
"pushed_at": "2026-09-11T10:43:22Z",
|
||||
"updated_at": "2026-03-25",
|
||||
"main_image": "icewhaletech/copyparty:latest",
|
||||
"category": "downloads",
|
||||
"category_label": "Files & Downloads",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/copyparty.json",
|
||||
"template_status": "laboratory-validated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -2964,8 +2964,8 @@
|
||||
],
|
||||
"updated_at": "2026-09-16",
|
||||
"main_image": "gtstef/filebrowser:latest",
|
||||
"category": "tools",
|
||||
"category_label": "Tools",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"replaces_discovered_ids": [],
|
||||
"curated_path": "catalog/curated/filebrowser-quantum.json",
|
||||
"template": "apps/filebrowser-quantum.json",
|
||||
@@ -3002,8 +3002,8 @@
|
||||
"pushed_at": "2026-09-11T10:43:22Z",
|
||||
"updated_at": "2023-09-13",
|
||||
"main_image": "noecl/filedrop:latest",
|
||||
"category": "productivity",
|
||||
"category_label": "Productivity & Workflows",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/filedrop.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -7576,8 +7576,8 @@
|
||||
"pushed_at": "2026-09-11T10:43:22Z",
|
||||
"updated_at": "2026-05-25",
|
||||
"main_image": "openlistteam/openlist:latest",
|
||||
"category": "productivity",
|
||||
"category_label": "Productivity & Workflows",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/openlist.json",
|
||||
"template_status": "laboratory-validated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -7906,8 +7906,8 @@
|
||||
"pushed_at": "2026-09-12T14:37:28Z",
|
||||
"updated_at": "2025-07-05",
|
||||
"main_image": "lscr.io/linuxserver/pairdrop:latest",
|
||||
"category": "downloads",
|
||||
"category_label": "Files & Downloads",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/pairdrop.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -8317,8 +8317,8 @@
|
||||
"pushed_at": "2026-09-11T10:43:22Z",
|
||||
"updated_at": "2025-05-25",
|
||||
"main_image": "stonith404/pingvin-share:latest",
|
||||
"category": "productivity",
|
||||
"category_label": "Productivity & Workflows",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/pingvin-share.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -8791,8 +8791,8 @@
|
||||
"pushed_at": "2026-09-11T10:43:22Z",
|
||||
"updated_at": "2026-02-14",
|
||||
"main_image": "psitrax/psitransfer:latest",
|
||||
"category": "productivity",
|
||||
"category_label": "Productivity & Workflows",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/psitransfer.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -8823,8 +8823,8 @@
|
||||
"pushed_at": "2026-09-07T20:21:35Z",
|
||||
"updated_at": "2026-07-17",
|
||||
"main_image": "lscr.io/linuxserver/pwndrop:latest",
|
||||
"category": "downloads",
|
||||
"category_label": "Files & Downloads",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/pwndrop.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -8886,8 +8886,8 @@
|
||||
"pushed_at": "2026-08-27T14:24:17Z",
|
||||
"updated_at": "2026-07-17",
|
||||
"main_image": "lscr.io/linuxserver/pydio-cells:latest",
|
||||
"category": "downloads",
|
||||
"category_label": "Files & Downloads",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/pydio-cells.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -9359,8 +9359,8 @@
|
||||
"pushed_at": "2026-09-08T01:30:09Z",
|
||||
"updated_at": "2024-08-21",
|
||||
"main_image": "lscr.io/linuxserver/resilio-sync:latest",
|
||||
"category": "downloads",
|
||||
"category_label": "Files & Downloads",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/resilio-sync.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -10016,8 +10016,8 @@
|
||||
"pushed_at": "2026-09-11T10:43:22Z",
|
||||
"updated_at": "2024-07-18",
|
||||
"main_image": "linuxserver/snapdrop:latest",
|
||||
"category": "productivity",
|
||||
"category_label": "Productivity & Workflows",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/snapdrop.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -10486,8 +10486,8 @@
|
||||
"pushed_at": "2026-09-10T15:29:13Z",
|
||||
"updated_at": "2026-09-10",
|
||||
"main_image": "lscr.io/linuxserver/syncthing:latest",
|
||||
"category": "downloads",
|
||||
"category_label": "Files & Downloads",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/syncthing.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
@@ -12077,8 +12077,8 @@
|
||||
"pushed_at": "2026-09-13T07:29:57Z",
|
||||
"updated_at": "2026-07-05",
|
||||
"main_image": "lscr.io/linuxserver/xbackbone:latest",
|
||||
"category": "downloads",
|
||||
"category_label": "Files & Downloads",
|
||||
"category": "sharing",
|
||||
"category_label": "Files & Sharing",
|
||||
"template": "apps/xbackbone.json",
|
||||
"template_status": "generated-unvalidated",
|
||||
"automatic_install_candidate": true,
|
||||
|
||||
@@ -116,5 +116,10 @@
|
||||
"unpackerr": [
|
||||
"/data"
|
||||
]
|
||||
},
|
||||
"sizes": {
|
||||
"sharecovex": {
|
||||
"/config": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1779,12 +1779,42 @@ 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.
|
||||
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
|
||||
# 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
|
||||
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
|
||||
if [[ $group != 0 && $group != "$HOST_BIND_GID" ]]; then
|
||||
triplet=$(stat -c '%A' "$source")
|
||||
triplet=${triplet:4:3}
|
||||
triplet=${triplet//s/x}
|
||||
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
|
||||
find "$source" -xdev -type d -exec setfacl -m "$default_spec" {} + 2>>"${OCI_LOG:-/dev/null}" || return 1
|
||||
}
|
||||
|
||||
MOUNT_ENTRIES=$(jq '.mounts | if type == "array" then length else 0 end' "$DEPLOYMENT_FILE")
|
||||
MOUNT_NOTES=()
|
||||
MOUNT_WARNINGS=()
|
||||
if (( MOUNT_ENTRIES > 0 )); then
|
||||
msg_info "$(translate "Adding the mount points...")"
|
||||
fi
|
||||
while IFS=$'\t' read -r TYPE TARGET SOURCE SIZE BACKUP READ_ONLY CREATE_IF_MISSING; do
|
||||
while IFS=$'\t' read -r TYPE TARGET SOURCE SIZE BACKUP READ_ONLY CREATE_IF_MISSING GRANT_ACCESS; do
|
||||
[[ -n $TYPE ]] || continue
|
||||
[[ $TARGET == /* && $TARGET != *","* ]] || die "$(translate "Invalid container path:") $TARGET"
|
||||
RO_OPT=""
|
||||
@@ -1821,6 +1851,15 @@ while IFS=$'\t' read -r TYPE TARGET SOURCE SIZE BACKUP READ_ONLY CREATE_IF_MISSI
|
||||
MOUNT_NOTES+=("$(translate "Shared directory created:") $SOURCE")
|
||||
fi
|
||||
[[ -e $SOURCE ]] || die "$(translate "The host bind source does not exist:") $SOURCE"
|
||||
if [[ -d $SOURCE && $GRANT_ACCESS == true ]]; then
|
||||
if grant_shared_access "$SOURCE"; then
|
||||
oci_log "Access granted with an ACL: $SOURCE (uid=$HOST_BIND_UID gid=$HOST_BIND_GID)"
|
||||
MOUNT_NOTES+=("$(translate "Access granted to the application on:") $SOURCE")
|
||||
else
|
||||
oci_log "Could not grant access with an ACL: $SOURCE"
|
||||
MOUNT_WARNINGS+=("$(translate "Access could not be granted; the application cannot write to:") $SOURCE")
|
||||
fi
|
||||
fi
|
||||
if [[ -d $SOURCE ]]; then
|
||||
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
|
||||
python3 "${SCRIPT_DIR}/oci_instance_transaction.py" --root "$INSTANCE_ROOT" \
|
||||
@@ -1847,13 +1886,16 @@ while IFS=$'\t' read -r TYPE TARGET SOURCE SIZE BACKUP READ_ONLY CREATE_IF_MISSI
|
||||
oci_quiet pct set "$VMID" "--mp${MOUNT_INDEX}" "$MP_VALUE" \
|
||||
|| die "$(translate "Could not add the mount point:") $TARGET"
|
||||
MOUNT_INDEX=$((MOUNT_INDEX + 1))
|
||||
done < <(jq -r '.mounts[] | [.type,.container_path,.source,(.size_gb // "-"),(.backup | if . then 1 else 0 end),.read_only,(.create_if_missing // false)] | @tsv' "$DEPLOYMENT_FILE")
|
||||
done < <(jq -r '.mounts[] | [.type,.container_path,.source,(.size_gb // "-"),(.backup | if . then 1 else 0 end),.read_only,(.create_if_missing // false),(.grant_access // false)] | @tsv' "$DEPLOYMENT_FILE")
|
||||
if (( MOUNT_ENTRIES > 0 )); then
|
||||
msg_ok "$(translate "Mount points added:") $MOUNT_ENTRIES"
|
||||
fi
|
||||
for MOUNT_NOTE in ${MOUNT_NOTES[@]+"${MOUNT_NOTES[@]}"}; do
|
||||
msg_ok "$MOUNT_NOTE"
|
||||
done
|
||||
for MOUNT_WARNING in ${MOUNT_WARNINGS[@]+"${MOUNT_WARNINGS[@]}"}; do
|
||||
msg_warn "$MOUNT_WARNING"
|
||||
done
|
||||
|
||||
while IFS=$'\t' read -r TARGET SIZE_MB OPTIONS; do
|
||||
[[ -n $TARGET ]] || continue
|
||||
|
||||
@@ -79,6 +79,20 @@ def host_directories(root, members):
|
||||
return paths
|
||||
|
||||
|
||||
def granted_directories(root, members):
|
||||
"""The host directories the installation was given access to with an ACL."""
|
||||
paths = []
|
||||
for vmid in members:
|
||||
try:
|
||||
record = instances.read(root, vmid)
|
||||
except (OSError, ValueError, KeyError):
|
||||
continue
|
||||
for mount in record.get('deployment', {}).get('mounts', []):
|
||||
if mount.get('grant_access') is True and mount.get('source') not in paths:
|
||||
paths.append(mount['source'])
|
||||
return paths
|
||||
|
||||
|
||||
def private_bridge(primary):
|
||||
network = (primary.get('stack') or {}).get('deployment', {}).get('network', {})
|
||||
bridge = network.get('private_bridge')
|
||||
@@ -313,6 +327,7 @@ def remove(root, vmid):
|
||||
raise ValueError(f"{translate('The container runs on another node of the cluster; migrate it back to this node to remove it:')} "
|
||||
f"CT {member} ({node})")
|
||||
kept = host_directories(root, members)
|
||||
granted = granted_directories(root, members)
|
||||
bridge = private_bridge(primary)
|
||||
incomplete = False
|
||||
msg_info(translate('Removing the containers...'))
|
||||
@@ -359,6 +374,9 @@ def remove(root, vmid):
|
||||
msg_ok(f"{translate('Unused image removed from the cache:')} {path.name}")
|
||||
for path in kept:
|
||||
msg_info(f"{translate('Host directory listed in saved records (not targeted for removal):')} {path}")
|
||||
for path in granted:
|
||||
# Another container may write to it with the same identity.
|
||||
msg_info(f"{translate('The access rule added to this host directory is kept:')} {path}")
|
||||
return incomplete
|
||||
|
||||
|
||||
|
||||
@@ -368,7 +368,8 @@ class Catalog:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
self._volume_policy = {"shared_paths": set(data.get("shared_paths", [])),
|
||||
"applications": data.get("applications", {})}
|
||||
"applications": data.get("applications", {}),
|
||||
"sizes": data.get("sizes", {})}
|
||||
return self._volume_policy
|
||||
|
||||
def _apply_volume_policy(self, app_id: str, contract: dict[str, Any]) -> None:
|
||||
@@ -377,6 +378,8 @@ class Catalog:
|
||||
state of the application stays in a container volume without asking."""
|
||||
policy = self.volume_policy()
|
||||
shared = policy["shared_paths"] | set(policy["applications"].get(app_id, []))
|
||||
# A volume known to stay small is created at its stated size.
|
||||
sizes = policy.get("sizes", {}).get(app_id, {})
|
||||
for volume in contract.get("volumes", []):
|
||||
choices = volume.get("installation_choice", [])
|
||||
if "managed-volume" not in choices:
|
||||
@@ -390,7 +393,9 @@ class Catalog:
|
||||
volume["default"] = "managed-volume"
|
||||
managed = volume.get("managed_volume")
|
||||
if isinstance(managed, dict):
|
||||
managed["default_size_gb"] = max(int(managed.get("default_size_gb") or 0), MINIMUM_VOLUME_GB)
|
||||
stated = sizes.get(volume["container_path"])
|
||||
managed["default_size_gb"] = (int(stated) if stated else
|
||||
max(int(managed.get("default_size_gb") or 0), MINIMUM_VOLUME_GB))
|
||||
|
||||
def _enrich_index_from_templates(self, payload: dict[str, Any]) -> None:
|
||||
for item in payload.get("applications", []):
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -81,6 +82,33 @@ def ipv4_subnet(bridge: str) -> str | None:
|
||||
return str(interface.network) if interface.version == 4 else None
|
||||
|
||||
|
||||
def can_write(path: str, uid: int, gid: int) -> bool:
|
||||
"""Whether this identity of the host can create files in an existing
|
||||
directory, by its owner, its group, everybody else or an ACL entry."""
|
||||
try:
|
||||
status = os.stat(path)
|
||||
except OSError:
|
||||
return False
|
||||
if status.st_uid == uid:
|
||||
bits = status.st_mode >> 6
|
||||
elif status.st_gid == gid:
|
||||
bits = status.st_mode >> 3
|
||||
else:
|
||||
bits = status.st_mode
|
||||
if bits & 3 == 3:
|
||||
return True
|
||||
try:
|
||||
listing = subprocess.run(["getfacl", "-cpn", path], capture_output=True, text=True, check=False).stdout
|
||||
except OSError:
|
||||
return False
|
||||
for line in listing.splitlines():
|
||||
fields = line.split(":")
|
||||
if (len(fields) >= 3 and fields[0] in ("user", "group") and fields[1] == str(uid if fields[0] == "user" else gid)
|
||||
and "w" in fields[2][:3] and "x" in fields[2][:3]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def timezone() -> str:
|
||||
try:
|
||||
value = Path("/etc/timezone").read_text(encoding="utf-8").strip()
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Any
|
||||
|
||||
from . import host
|
||||
from . import network as access
|
||||
from .i18n import translate
|
||||
from .i18n import source_text, translate
|
||||
from .ui import DialogUI, TerminalUI, UserCancelled
|
||||
from .custom_mounts import ask_custom_mounts
|
||||
from .extra_devices import device_permissions
|
||||
@@ -141,7 +141,7 @@ SHARED_DIRECTORY_NAME = re.compile(r"[a-z][a-z0-9_-]{0,31}")
|
||||
|
||||
|
||||
def ask_shared_directories(ui, template: dict[str, Any], mounts: list[dict[str, Any]], storage: str,
|
||||
advanced: bool, optional: bool = False) -> list[dict[str, Any]]:
|
||||
advanced: bool, optional: bool = False, unprivileged: bool = True) -> list[dict[str, Any]]:
|
||||
"""The directories an application exists to share. Its profile declares one
|
||||
container directory, and each directory the user names is mounted below
|
||||
it, on a container volume or from a directory of the host. A new
|
||||
@@ -185,6 +185,22 @@ def ask_shared_directories(ui, template: dict[str, Any], mounts: list[dict[str,
|
||||
else:
|
||||
mount["source"] = ui.ask(f"{translate('Host path for')} {target}",
|
||||
f"/mnt/oci-shared/{app_id}/{name}")
|
||||
# A directory that is already there belongs to whoever made it. The
|
||||
# identity the application writes with is the one of its volumes,
|
||||
# shifted by the range of an unprivileged container.
|
||||
owner = profile.get("volume_owner") or {}
|
||||
shift = 100000 if unprivileged else 0
|
||||
uid, gid = int(owner.get("uid", 0)) + shift, int(owner.get("gid", 0)) + shift
|
||||
if os.path.isdir(mount["source"]) and not host.can_write(mount["source"], uid, gid):
|
||||
title = source_text(template["catalog_ui"]["title"]) or app_id
|
||||
if ui.confirm(translate(
|
||||
"{path} already exists and belongs to another user of the host, so {app} cannot "
|
||||
"write to it.\n\nProxMenux can add an access rule (ACL) for the identity {app} uses "
|
||||
"on the host (UID {uid}). Nothing that is there changes owner or permissions: "
|
||||
"whatever reads or writes those files today keeps doing so, and the owner of the "
|
||||
"directory also gets access to the files {app} creates.\n\nGrant {app} access to "
|
||||
"this directory?").format(path=mount["source"], app=title, uid=uid), True):
|
||||
mount["grant_access"] = True
|
||||
from .custom_mounts import validate_mount
|
||||
mount["container_path"] = validate_mount(mount, [*mounts, *added])
|
||||
added.append(mount)
|
||||
@@ -485,7 +501,8 @@ def build_deployment(
|
||||
)
|
||||
|
||||
if installer_profile.get("shared_directories"):
|
||||
mounts += ask_shared_directories(ui, template, mounts, volume_storage, advanced)
|
||||
mounts += ask_shared_directories(ui, template, mounts, volume_storage, advanced,
|
||||
unprivileged=unprivileged)
|
||||
|
||||
if advanced:
|
||||
mounts = ask_custom_mounts(ui, mounts, volume_storage)
|
||||
|
||||
@@ -115,7 +115,8 @@ def edit_recreation(record, ui):
|
||||
if template.get('proxmox', {}).get('installer_profile', {}).get('shared_directories'):
|
||||
from .installer import ask_shared_directories
|
||||
deployment['mounts'] += ask_shared_directories(
|
||||
ui, template, deployment['mounts'], deployment['rootfs']['storage'], True, optional=True)
|
||||
ui, template, deployment['mounts'], deployment['rootfs']['storage'], True, optional=True,
|
||||
unprivileged=deployment.get('security', {}).get('unprivileged', True))
|
||||
from .custom_mounts import ask_custom_mounts
|
||||
deployment['mounts'] = ask_custom_mounts(
|
||||
ui, deployment['mounts'], deployment['rootfs']['storage'])
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
installation, and its privileged profile is offered by the advanced one only."""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -10,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from proxmenux_oci import host
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, InstallError, build_deployment
|
||||
from proxmenux_oci.recreation import edit_recreation
|
||||
@@ -101,6 +104,10 @@ class SharedDirectoriesTests(unittest.TestCase):
|
||||
self.assertFalse(any("ShareCoveX panel administrator" in text for text in ui.asked))
|
||||
self.assertEqual([m["container_path"] for m in plan["mounts"]], ["/config", "/shares/media"])
|
||||
self.assertEqual(plan["mounts"][1]["size_gb"], 32)
|
||||
# The settings stay small: their volume is created at the size the policy states.
|
||||
self.assertEqual(plan["mounts"][0]["size_gb"], 1)
|
||||
other = self.catalog.compose("seerr")["container_contract"]["volumes"][0]["managed_volume"]
|
||||
self.assertGreaterEqual(other["default_size_gb"], 16)
|
||||
|
||||
def test_only_the_advanced_installation_offers_nfsv4_and_it_makes_the_container_privileged(self, *_):
|
||||
ui, _template, plan = self.build({NAME: ["media"]}, ADVANCED_MODE)
|
||||
@@ -125,6 +132,70 @@ class SharedDirectoriesTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(InstallError, "minimum number of characters is 8"):
|
||||
self.build({NAME: ["media"], "(empty = generate)": ["short"]}, ADVANCED_MODE)
|
||||
|
||||
def test_an_existing_directory_the_application_cannot_write_to_offers_access(self, *_):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
answers = {NAME: ["media"], "Where to store /shares/media": ["host-bind"],
|
||||
"Host path for /shares/media": [directory]}
|
||||
with patch("proxmenux_oci.installer.host.can_write", return_value=False) as check:
|
||||
ui, _template, plan = self.build(answers)
|
||||
question = next(text for text in ui.asked if "Grant ShareCoveX access" in text)
|
||||
# The identity is the one of the volumes, in the range of an unprivileged container.
|
||||
check.assert_called_once_with(directory, 101000, 101000)
|
||||
self.assertIn("UID 101000", question)
|
||||
self.assertIn("changes owner or permissions", question)
|
||||
self.assertIs(plan["mounts"][1]["grant_access"], True)
|
||||
_ui, _template, declined = self.build({**answers, question: [False]})
|
||||
self.assertNotIn("grant_access", declined["mounts"][1])
|
||||
with patch("proxmenux_oci.installer.host.can_write", return_value=True):
|
||||
ui, _template, plan = self.build(answers)
|
||||
self.assertFalse(any("Grant ShareCoveX access" in text for text in ui.asked))
|
||||
self.assertNotIn("grant_access", plan["mounts"][1])
|
||||
# A privileged container writes with the identity as it is.
|
||||
nfsv4 = "NFSv4 needs a privileged container"
|
||||
with patch("proxmenux_oci.installer.host.can_write", return_value=False) as check:
|
||||
ui = ScriptedUI(answers)
|
||||
template = self.catalog.compose("sharecovex")
|
||||
first = build_deployment(template, ui, ADVANCED_MODE)
|
||||
prompt = next(text for text in ui.asked if nfsv4 in text)
|
||||
check.reset_mock()
|
||||
build_deployment(self.catalog.compose("sharecovex"), ScriptedUI({**answers, prompt: [True]}),
|
||||
ADVANCED_MODE)
|
||||
check.assert_called_once_with(directory, 1000, 1000)
|
||||
self.assertIsNotNone(first)
|
||||
|
||||
def test_a_directory_that_does_not_exist_yet_is_created_for_the_application(self, *_):
|
||||
with patch("proxmenux_oci.installer.host.can_write") as check:
|
||||
ui, _template, plan = self.build({NAME: ["media"], "Where to store /shares/media": ["host-bind"],
|
||||
"Host path for /shares/media": ["/nonexistent/proxmenux-test/media"]})
|
||||
check.assert_not_called()
|
||||
self.assertNotIn("grant_access", plan["mounts"][1])
|
||||
self.assertIs(plan["mounts"][1]["create_if_missing"], True)
|
||||
|
||||
def test_write_access_is_read_from_the_mode_bits(self, *_):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
status = os.stat(directory)
|
||||
os.chmod(directory, 0o750)
|
||||
self.assertTrue(host.can_write(directory, status.st_uid, 99999))
|
||||
self.assertFalse(host.can_write(directory, 99998, status.st_gid))
|
||||
self.assertFalse(host.can_write(directory, 99998, 99999))
|
||||
os.chmod(directory, 0o770)
|
||||
self.assertTrue(host.can_write(directory, 99998, status.st_gid))
|
||||
os.chmod(directory, 0o757)
|
||||
self.assertTrue(host.can_write(directory, 99998, 99999))
|
||||
os.chmod(directory, 0o700)
|
||||
self.assertFalse(host.can_write("/nonexistent/proxmenux-test", 0, 0))
|
||||
|
||||
def test_the_installer_script_grants_access_only_when_asked(self, *_):
|
||||
script = (ROOT / "remote/install_oci.sh").read_text()
|
||||
self.assertIn("(.grant_access // false)] | @tsv", script)
|
||||
self.assertIn('if [[ -d $SOURCE && $GRANT_ACCESS == true ]]; then', script)
|
||||
# The owner is never changed, and the rule is not applied twice.
|
||||
grant = script[script.index("grant_shared_access() {"):script.index("MOUNT_ENTRIES=")]
|
||||
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)
|
||||
|
||||
def test_a_name_that_cannot_identify_a_share_is_refused(self, *_):
|
||||
for name in ("Media", "my files", "9lives", "config", "", "a" * 33):
|
||||
with self.assertRaisesRegex(ValueError, "lowercase"):
|
||||
|
||||
Reference in New Issue
Block a user