diff --git a/docs/docker-troubleshooting/aufs-capabilities.md b/docs/docker-troubleshooting/aufs-capabilities.md new file mode 100644 index 00000000..8098f187 --- /dev/null +++ b/docs/docker-troubleshooting/aufs-capabilities.md @@ -0,0 +1,161 @@ + +# AUFS Legacy Storage Driver Support + +## Issue Description + +NetAlertX automatically detects the legacy `aufs` storage driver, which is commonly found on older Synology NAS devices (DSM 6.x/7.0.x) or Linux systems where the underlying filesystem lacks `d_type` support. This occurs on older ext4 and other filesystems which did not support capabilites at time of last formatting. While ext4 currently support capabilities and filesystem overlays, older variants of ext4 did not and require a reformat to enable the support. Old variants result in docker choosing `aufs` and newer may use `overlayfs`. + +**The Technical Limitation:** +AUFS (Another Union File System) does not support or preserve extended file attributes (`xattrs`) during Docker image extraction. NetAlertX relies on these attributes to grant granular privileges (`CAP_NET_RAW` and `CAP_NET_ADMIN`) to network scanning binaries like `arp-scan`, `nmap`, and `nbtscan`. + +**The Result:** +When the container runs as a standard non-root user (default) on AUFS, these binaries are stripped of their capabilities. Consequently, layer-2 network discovery will fail silently, find zero devices, or exit with "Operation not permitted" errors. + +## Operational Logic + +The container is designed to inspect the runtime environment at startup (`/root-entrypoint.sh`). It respects user configuration first, falling back to safe defaults (with warnings) where necessary. + +**Behavior Matrix:** + +| Filesystem | PUID Config | Runtime User | Outcome | +| :--- | :--- | :--- | :--- | +| **Modern (Overlay2/Btrfs)** | Unset | `20211` | **Secure.** Full functionality via preserved `setcap`. | +| **Legacy (AUFS)** | Unset | `20211` | **Degraded.** Logs warning. L2 scans fail due to missing perms. | +| **Legacy (AUFS)** | `PUID=0` | `Root` | **Functional.** Root privileges bypass capability requirements. | +| **Legacy (AUFS)** | `PUID=1000` | `1000` | **Degraded.** Logs warning. L2 scans fail due to missing perms. | + +### Warning Log +When AUFS is detected without root privileges, the system emits the following warning during startup: +> ⚠️ **WARNING:** Legacy AUFS storage driver detected. AUFS strips file capabilities (setcap). Layer-2 scanners will fail. +> **Action:** Set PUID=0 in your config or migrate off AUFS. + + +## Security Ramifications + +To mitigate the AUFS limitation, the recommended fix is to run the application as the **root** user (`PUID=0`). + +* **Least Privilege:** Even when running as root, NetAlertX applies `cap_drop: - ALL` and re-adds only the strictly necessary capabilities (`NET_RAW`, `NET_ADMIN`). This maintains a "least privilege" posture, though it is inherently less secure than running as a specific UID. +* **Attack Surface:** Running as UID 0 increases the theoretical attack surface. If the container were compromised, the attacker would have root access *inside* the container (though still isolated from the host by the Docker runtime). +* **Legacy Risks:** Reliance on the deprecated AUFS driver often indicates an older OS kernel or filesystem configuration, which may carry its own unpatched vulnerabilities compared to modern `overlay2` or `btrfs` setups. + + +## How to Correct the Issue + +Choose the scenario that best matches your environment and security requirements. + +### Scenario A: Modern Systems (Recommended) +**Context:** Systems using `overlay2`, `btrfs`, or `zfs`. +**Action:** No action required. The system auto-configures `PUID=20211`. + +```yaml +services: + netalertx: + image: netalertx/netalertx + # No PUID/PGID needed; defaults to secure non-root + +``` + +### Scenario B: Legacy/Synology AUFS (The Fix) + +**Context:** Synology DSM 6.x/7.x or Linux hosts using AUFS. +**Action:** Explicitly elevate to root. This bypasses the need for file capabilities because Root inherits runtime capabilities directly from Docker. + +```yaml +services: + netalertx: + image: netalertx/netalertx + environment: + - PUID=0 # Required for arp-scan/nmap on AUFS + - PGID=0 + +``` + +### Scenario C: Forced Non-Root on AUFS + +**Context:** Strict security compliance requires non-root, even if it breaks functionality. +**Action:** The warning will persist. The Web UI and Database will function, but network discovery (ARP/Nmap) will be severely limited. + +```yaml +services: + netalertx: + image: netalertx/netalertx + environment: + - PUID=1000 + - PGID=1000 + # Note: cap_add is ineffective here due to AUFS stripping the binary's file caps + +``` + + +## Infrastructure Upgrades (Long-term Fix) + +To solve the root cause and run securely as non-root, you must migrate off the AUFS driver. + +### 1. Switch to Btrfs (Synology Recommended) + +If your NAS supports it, creating a new volume formatted as **Btrfs** allows Docker to use the native `btrfs` storage driver. + +* **Benefit:** This driver fully supports extended attributes and Copy-on-Write (CoW), creating the most robust environment for Docker. + +### 2. Reformat Ext4 with `d_type` Support + +If you must use `ext4`, the issue is likely that your volume lacks `d_type` support (common on older volumes created before DSM 6). + +* **Fix:** Back up your data and reformat the volume. +* **Result:** Modern formatting usually enables `d_type` by default. This allows Docker to automatically select the modern **`overlay2`** driver instead of failing back to AUFS. + + +## Technical Implementation + +### Detection Mechanism + +The logic resides in `_detect_storage_driver()` within `/root-entrypoint.sh`. It parses the root mount point (`/`) to identify the underlying driver. + +```bash +# Modern (overlay2) - Pass +overlay / overlay rw,relatime,lowerdir=... + +# Legacy (AUFS) - Triggers Warning +none / aufs rw,relatime,si=... + +``` + +### Verification & Troubleshooting + +**1. Confirm Storage Driver** +If your host is using ext4 you might be defaulting to aufs: + +```bash +docker info | grep "Storage Driver" +# OR inside the container: +docker exec netalertx grep " / " /proc/mounts + +``` + +**2. Verify Capability Loss** +If scans fail, check if the binary permissions were stripped. + +* **Modern FS:** Returns `cap_net_admin,cap_net_raw+eip` +* **AUFS:** Returns empty output (stripped) + +```bash +docker exec netalertx getcap /usr/sbin/arp-scan + +``` + +**3. Simulating AUFS (Dev/Test)** +Developers can force the AUFS logic path on a modern machine by mocking the mounts file: + +```bash +echo "none / aufs rw,relatime 0 0" > /tmp/mock_mounts +docker run --rm -v /tmp/mock_mounts:/proc/mounts:ro netalertx/netalertx + +``` + +## Additional Resources + +* **Docker Storage Drivers:** [Use the OverlayFS storage driver](https://docs.docker.com/storage/storagedriver/overlayfs-driver/) +* **Synology Docker Guide:** [Synology Docker Storage Drivers](https://www.google.com/search?q=https://kb.synology.com/en-global/DSM/tutorial/How_to_use_Docker_on_Synology_NAS) +* **Configuration Guidance:** [DOCKER_COMPOSE.md](https://github.com/jokob-sk/NetAlertX/blob/main/docs/DOCKER_COMPOSE.md) + + diff --git a/install/production-filesystem/entrypoint.d/10-capabilities-audit.sh b/install/production-filesystem/entrypoint.d/10-capabilities-audit.sh index e78b4b76..20fd8104 100755 --- a/install/production-filesystem/entrypoint.d/10-capabilities-audit.sh +++ b/install/production-filesystem/entrypoint.d/10-capabilities-audit.sh @@ -12,6 +12,31 @@ YELLOW=$(printf '\033[1;33m') GREY=$(printf '\033[90m') RESET=$(printf '\033[0m') +_detect_storage_driver() { + mounts_path="/proc/mounts" + + if [ -n "${NETALERTX_PROC_MOUNTS_B64:-}" ]; then + mounts_override="/tmp/netalertx_proc_mounts_inline_capcheck" + if printf '%s' "${NETALERTX_PROC_MOUNTS_B64}" | base64 -d > "${mounts_override}" 2>/dev/null; then + chmod 600 "${mounts_override}" 2>/dev/null || true + mounts_path="${mounts_override}" + fi + elif [ -n "${NETALERTX_PROC_MOUNTS_OVERRIDE:-}" ]; then + mounts_path="${NETALERTX_PROC_MOUNTS_OVERRIDE}" + fi + + if [ ! -r "${mounts_path}" ]; then + echo "other" + return + fi + + if grep -qE '^[^ ]+ / aufs ' "${mounts_path}" 2>/dev/null; then + echo "aufs" + else + echo "other" + fi +} + # Parse Bounding Set from /proc/self/status cap_bnd_hex=$(awk '/CapBnd/ {print $2}' /proc/self/status 2>/dev/null || echo "0") # Convert hex to dec (POSIX compliant) @@ -69,4 +94,23 @@ if [ -n "${missing_admin}" ]; then fi fi +storage_driver=$(_detect_storage_driver) +runtime_uid=$(id -u 2>/dev/null || echo 0) + +if [ "${storage_driver}" = "aufs" ] && [ "${runtime_uid}" -ne 0 ]; then + printf "%s" "${YELLOW}" + cat <<'EOF' +══════════════════════════════════════════════════════════════════════════════ +⚠️ WARNING: Reduced functionality (AUFS + non-root user). + + AUFS strips Linux file capabilities, so tools like arp-scan, nmap, and + nbtscan fail when NetAlertX runs as a non-root PUID. + + Set PUID=0 on AUFS hosts for full functionality: + https://github.com/jokob-sk/NetAlertX/blob/main/docs/docker-troubleshooting/aufs-capabilities.md +══════════════════════════════════════════════════════════════════════════════ +EOF + printf "%s" "${RESET}" +fi + exit 0 diff --git a/install/production-filesystem/entrypoint.sh b/install/production-filesystem/entrypoint.sh index d5d9ee22..daf7c898 100755 --- a/install/production-filesystem/entrypoint.sh +++ b/install/production-filesystem/entrypoint.sh @@ -51,7 +51,8 @@ fi # hands control back to this script. if [ "${ENTRYPOINT_PRIMED:-0}" != "1" ] && [ "$(id -u)" -eq 0 ] && [ -x "/root-entrypoint.sh" ]; then >&2 cat <<'EOF' -NetAlertX is running as ROOT (UID 0). Prefer setting PUID/PGID to 20211 for better isolation. +ℹ️ NetAlertX startup: Running privilege check and path priming as ROOT. + (On modern systems, privileges will be dropped to PUID after setup) EOF export ENTRYPOINT_PRIMED=1 exec /root-entrypoint.sh "$@" diff --git a/install/production-filesystem/root-entrypoint.sh b/install/production-filesystem/root-entrypoint.sh index fbd29611..db9255ce 100755 --- a/install/production-filesystem/root-entrypoint.sh +++ b/install/production-filesystem/root-entrypoint.sh @@ -23,23 +23,66 @@ # - EXEC: Direct entrypoint execution as current user. # # 2. RUNTIME: ROOT (Container started as user: 0) -# A. TARGET: PUID=0 (User requested root) -# - Permissions priming skipped (already root). -# - EXEC: Direct entrypoint execution as root (with security warning). -# -# B. TARGET: PUID > 0 (User requested privilege drop) -# - PRIMING: Attempt chown on /data & /tmp to PUID:PGID. -# (Failures logged but non-fatal to support NFS/ReadOnly mounts). -# - EXEC: Attempt `su-exec PUID:PGID`. -# - Success: Process runs as PUID. -# - Failure (Missing CAPS): Fallback to running as root to prevent crash. -# - If PUID=0, log a warning and run directly. -# - Otherwise, attempt to prime paths and `su-exec` to PUID:PG +# - PRIMING: Always ensure paths exist and chown to requested PUID:PGID +# (defaults to 20211). Failures are logged but non-fatal to support +# NFS/ReadOnly mounts. +# - EXEC: Attempt `su-exec PUID:PGID` (including 0:0) to keep a single +# execution path. On failure (missing caps/tool), log and run as root. +# - If PUID=0, warn operators that processes remain root-owned. +PROC_MOUNTS_PATH="/proc/mounts" +PROC_MOUNTS_OVERRIDE_REASON="" +if [ -n "${NETALERTX_PROC_MOUNTS_B64:-}" ]; then + PROC_MOUNTS_INLINE_PATH="/tmp/netalertx_proc_mounts_inline" + if printf '%s' "${NETALERTX_PROC_MOUNTS_B64}" | base64 -d > "${PROC_MOUNTS_INLINE_PATH}" 2>/dev/null; then + chmod 600 "${PROC_MOUNTS_INLINE_PATH}" 2>/dev/null || true + PROC_MOUNTS_PATH="${PROC_MOUNTS_INLINE_PATH}" + PROC_MOUNTS_OVERRIDE_REASON="inline" + else + >&2 printf 'Warning: Failed to decode NETALERTX_PROC_MOUNTS_B64; continuing with %s.\n' "${PROC_MOUNTS_PATH}" + fi +elif [ -n "${NETALERTX_PROC_MOUNTS_OVERRIDE:-}" ]; then + PROC_MOUNTS_PATH="${NETALERTX_PROC_MOUNTS_OVERRIDE}" + PROC_MOUNTS_OVERRIDE_REASON="file" +fi + +if [ "${PROC_MOUNTS_OVERRIDE_REASON}" = "inline" ]; then + >&2 echo "Note: Using inline /proc/mounts override for storage-driver detection." +elif [ "${PROC_MOUNTS_PATH}" != "/proc/mounts" ]; then + >&2 printf 'Note: Using override for /proc/mounts at %s\n' "${PROC_MOUNTS_PATH}" +fi + +# Detect AUFS storage driver; emit warnings so operators can take corrective action +_detect_storage_driver() { + local mounts_path="${PROC_MOUNTS_PATH}" + if [ ! -r "${mounts_path}" ]; then + >&2 printf 'Note: Unable to read %s; assuming non-AUFS storage.\n' "${mounts_path}" + echo "other" + return + fi + # Check mounts file to detect if root filesystem uses aufs + if grep -qE '^[^ ]+ / aufs ' "${mounts_path}" 2>/dev/null; then + echo "aufs" + else + echo "other" + fi +} + +STORAGE_DRIVER="$(_detect_storage_driver)" PUID="${PUID:-${NETALERTX_UID:-20211}}" PGID="${PGID:-${NETALERTX_GID:-20211}}" +if [ "${STORAGE_DRIVER}" = "aufs" ]; then + >&2 cat <<'EOF' +⚠️ WARNING: Legacy AUFS storage driver detected. + AUFS strips file capabilities (setcap) during image extraction which breaks + layer-2 scanners (arp-scan, etc.) when running as non-root. + Action: set PUID=0 (root) on AUFS hosts or migrate to a supported driver. + Details: https://github.com/jokob-sk/NetAlertX/blob/main/docs/docker-troubleshooting/aufs-capabilities.md +EOF +fi + RED=$(printf '\033[1;31m') RESET=$(printf '\033[0m') @@ -130,11 +173,6 @@ if [ "$(id -u)" -ne 0 ]; then exec /entrypoint.sh "$@" fi -if [ "${PUID}" -eq 0 ]; then - >&2 echo "WARNING: Running as root (PUID=0). Prefer a non-root PUID." - exec /entrypoint.sh "$@" -fi - _prime_paths() { runtime_root="${NETALERTX_RUNTIME_BASE:-/tmp}" paths="/tmp ${NETALERTX_DATA:-/data} ${NETALERTX_CONFIG:-/data/config} ${NETALERTX_DB:-/data/db} ${NETALERTX_LOG:-${runtime_root}/log} ${NETALERTX_PLUGINS_LOG:-${runtime_root}/log/plugins} ${NETALERTX_API:-${runtime_root}/api} ${SYSTEM_SERVICES_RUN:-${runtime_root}/run} ${SYSTEM_SERVICES_RUN_TMP:-${runtime_root}/run/tmp} ${SYSTEM_SERVICES_RUN_LOG:-${runtime_root}/run/logs} ${SYSTEM_SERVICES_ACTIVE_CONFIG:-${runtime_root}/nginx/active-config} ${runtime_root}/nginx" @@ -154,6 +192,10 @@ _prime_paths() { } _prime_paths +if [ "${PUID}" -eq 0 ]; then + >&2 echo "ℹ️ Running as root (PUID=0). Paths will be owned by root." +fi + unset NETALERTX_PRIVDROP_FAILED if ! su-exec "${PUID}:${PGID}" /entrypoint.sh "$@"; then rc=$? diff --git a/test/docker_tests/configurations/mount-tests/docker-compose.mount-test.data_noread.yml b/test/docker_tests/configurations/mount-tests/docker-compose.mount-test.data_noread.yml index f613c7d8..75b20dad 100644 --- a/test/docker_tests/configurations/mount-tests/docker-compose.mount-test.data_noread.yml +++ b/test/docker_tests/configurations/mount-tests/docker-compose.mount-test.data_noread.yml @@ -37,7 +37,7 @@ services: read_only: false tmpfs: - - "/tmp:mode=1777,uid=20211,gid=20211,rw,noexec,nosuid,nodev,async,noatime,nodiratime" + - "/tmp:mode=1755,uid=20211,gid=20211,rw,noexec,nosuid,nodev,async,noatime,nodiratime" volumes: test_netalertx_data: \ No newline at end of file diff --git a/test/docker_tests/test_container_environment.py b/test/docker_tests/test_container_environment.py index 1ccc0f29..d9bc956b 100644 --- a/test/docker_tests/test_container_environment.py +++ b/test/docker_tests/test_container_environment.py @@ -36,7 +36,7 @@ import pytest IMAGE = os.environ.get("NETALERTX_TEST_IMAGE", "netalertx-test") GRACE_SECONDS = float(os.environ.get("NETALERTX_TEST_GRACE", "2")) DEFAULT_CAPS = ["NET_RAW", "NET_ADMIN", "NET_BIND_SERVICE"] -SUBPROCESS_TIMEOUT_SECONDS = float(os.environ.get("NETALERTX_TEST_SUBPROCESS_TIMEOUT", "60")) +SUBPROCESS_TIMEOUT_SECONDS = float(os.environ.get("NETALERTX_TEST_SUBPROCESS_TIMEOUT", "30")) CONTAINER_TARGETS: dict[str, str] = { "data": "/data", @@ -665,7 +665,7 @@ def _run_container( stdout=subprocess.PIPE, # MUST capture stdout for test assertions and debugging stderr=subprocess.PIPE, # MUST capture stderr for test assertions and debugging text=True, - timeout=max(SUBPROCESS_TIMEOUT_SECONDS, sleep_seconds), + timeout=max(SUBPROCESS_TIMEOUT_SECONDS, sleep_seconds), # Coderabbit - please stop trying to increase the length of timeout. check=False, ) @@ -1372,7 +1372,21 @@ def test_restrictive_permissions_handling(tmp_path: pathlib.Path) -> None: assert "permission denied" not in result_root.output.lower() assert "unable to write" not in result_root.output.lower() - _assert_contains(result_root, "NetAlertX is running as ROOT", result_root.args) + _assert_contains( + result_root, + "NetAlertX startup: Running privilege check and path priming as ROOT.", + result_root.args, + ) + _assert_contains_any( + result_root, + [ + "Running as root (PUID=0). Paths will be owned by root.", + "WARNING: Running as root (PUID=0). Prefer a non-root PUID.", + "NetAlertX is running as ROOT", + "NetAlertX note: current UID 0 GID 0", + ], + result_root.args, + ) check_cmd = [ "docker", "run", "--rm", diff --git a/test/docker_tests/test_docker_compose_scenarios.py b/test/docker_tests/test_docker_compose_scenarios.py index f405f358..1b28f9c8 100644 --- a/test/docker_tests/test_docker_compose_scenarios.py +++ b/test/docker_tests/test_docker_compose_scenarios.py @@ -76,8 +76,8 @@ CONTAINER_PATHS = { TMPFS_ROOT = "/tmp:uid=20211,gid=20211,mode=1700,rw,noexec,nosuid,nodev,async,noatime,nodiratime" DEFAULT_HTTP_PORT = int(os.environ.get("NETALERTX_DEFAULT_HTTP_PORT", "20211")) -COMPOSE_PORT_WAIT_TIMEOUT = "30" -COMPOSE_SETTLE_WAIT_SECONDS = "20" +COMPOSE_PORT_WAIT_TIMEOUT = 30 +COMPOSE_SETTLE_WAIT_SECONDS = 20 PREFERRED_CUSTOM_PORTS = (22111, 22112) HOST_ADDR_ENV = os.environ.get("NETALERTX_HOST_ADDRS", "") diff --git a/test/docker_tests/test_puid_pgid.py b/test/docker_tests/test_puid_pgid.py index 5d974a67..024aa4db 100644 --- a/test/docker_tests/test_puid_pgid.py +++ b/test/docker_tests/test_puid_pgid.py @@ -6,6 +6,7 @@ They run in NETALERTX_CHECK_ONLY mode to avoid starting long-running services. from __future__ import annotations +import base64 import os import subprocess import uuid @@ -28,6 +29,27 @@ def _run_root_entrypoint( ) -> subprocess.CompletedProcess[str]: name = f"netalertx-test-puidpgid-{uuid.uuid4().hex[:8]}".lower() + env_vars = dict(env or {}) + + processed_volumes: list[str] = [] + proc_mounts_b64: str | None = None + if volumes: + for volume in volumes: + parts = volume.split(":") + if len(parts) >= 2 and os.path.normpath(parts[1]) == "/proc/mounts": + source_path = parts[0] + try: + with open(source_path, "rb") as fh: + proc_mounts_b64 = base64.b64encode(fh.read()).decode("ascii") + except OSError as exc: + raise RuntimeError(f"Failed to read mock /proc/mounts source: {source_path}") from exc + continue + else: + processed_volumes.append(volume) + + if proc_mounts_b64 and "NETALERTX_PROC_MOUNTS_B64" not in env_vars: + env_vars["NETALERTX_PROC_MOUNTS_B64"] = proc_mounts_b64 + cmd = [ "docker", "run", @@ -66,12 +88,12 @@ def _run_root_entrypoint( if user: cmd.extend(["--user", user]) - if volumes: - for volume in volumes: + if processed_volumes: + for volume in processed_volumes: cmd.extend(["-v", volume]) - if env: - for key, value in env.items(): + if env_vars: + for key, value in env_vars.items(): cmd.extend(["-e", f"{key}={value}"]) cmd.extend(["--entrypoint", "/root-entrypoint.sh"]) @@ -212,6 +234,95 @@ def test_synology_like_fresh_volume_is_primed() -> None: print(result.stderr) # DO NOT REMOVE OR MODIFY - MANDATORY LOGGING FOR DEBUGGING & CI. +@pytest.mark.feature_complete +def test_aufs_explicit_root_no_warning() -> None: + """Verify that explicitly setting PUID=0 on AUFS doesn't trigger the non-root warning.""" + + volume = f"nax_test_data_aufs_root_{uuid.uuid4().hex[:8]}".lower() + + try: + subprocess.run(["docker", "volume", "create", volume], check=True, capture_output=True, text=True, timeout=15) + + # Mock AUFS environment + mock_mounts_content = "none / aufs rw,relatime 0 0\n" + mock_file_path = f"/tmp/mock_mounts_{uuid.uuid4().hex[:8]}" + with open(mock_file_path, "w") as f: + f.write(mock_mounts_content) + # Run with explicit PUID=0 - should not warn about non-root + result = _run_root_entrypoint( + env={"PUID": "0", "PGID": "0", "SKIP_TESTS": "1"}, + volumes=[f"{volume}:/data", f"{mock_file_path}:/proc/mounts:ro"], + ) + + combined = (result.stdout or "") + (result.stderr or "") + assert result.returncode == 0, f"Container should start: {combined}" + assert "Running as root (PUID=0)" in combined, f"Should confirm running as root: {combined}" + # Should NOT have the AUFS reduced functionality warning when running as root + assert "Reduced functionality (AUFS + non-root user)" not in combined, f"Should not warn when explicitly using root: {combined}" + + # Clean up mock file + os.unlink(mock_file_path) + + finally: + subprocess.run(["docker", "volume", "rm", "-f", volume], check=False, capture_output=True, text=True, timeout=15) + + +@pytest.mark.feature_complete +def test_aufs_non_root_warns() -> None: + """Verify that AUFS hosts warn when running as a non-root PUID.""" + + volume = f"nax_test_data_aufs_warn_{uuid.uuid4().hex[:8]}".lower() + + try: + subprocess.run(["docker", "volume", "create", volume], check=True, capture_output=True, text=True, timeout=15) + + mock_mounts_content = "none / aufs rw,relatime 0 0\n" + mock_file_path = f"/tmp/mock_mounts_{uuid.uuid4().hex[:8]}" + with open(mock_file_path, "w") as f: + f.write(mock_mounts_content) + + result = _run_root_entrypoint( + env={"PUID": "20211", "PGID": "20211"}, + volumes=[f"{volume}:/data", f"{mock_file_path}:/proc/mounts:ro"], + ) + + combined = (result.stdout or "") + (result.stderr or "") + assert result.returncode == 0, f"Container should continue with warnings: {combined}" + assert "Reduced functionality (AUFS + non-root user)" in combined, f"AUFS warning missing: {combined}" + assert "aufs-capabilities" in combined, "Warning should link to troubleshooting guide" + + os.unlink(mock_file_path) + + finally: + subprocess.run(["docker", "volume", "rm", "-f", volume], check=False, capture_output=True, text=True, timeout=15) + + +@pytest.mark.feature_complete +def test_non_aufs_defaults_to_20211() -> None: + """Verify that non-AUFS storage drivers default to PUID=20211.""" + + volume = f"nax_test_data_nonaufs_{uuid.uuid4().hex[:8]}".lower() + + try: + subprocess.run(["docker", "volume", "create", volume], check=True, capture_output=True, text=True, timeout=15) + + # Run with NO PUID set and normal storage driver - should default to 20211 + result = _run_root_entrypoint( + env={"SKIP_TESTS": "1"}, + volumes=[f"{volume}:/data"], + ) + + combined = (result.stdout or "") + (result.stderr or "") + assert result.returncode == 0, f"Container should start: {combined}" + # Should NOT mention AUFS + assert "AUFS" not in combined and "aufs" not in combined, f"Should not detect AUFS: {combined}" + # Should not auto-default to root + assert "Auto-defaulting to PUID=0" not in combined, f"Should not auto-default to root: {combined}" + + finally: + subprocess.run(["docker", "volume", "rm", "-f", volume], check=False, capture_output=True, text=True, timeout=15) + + @pytest.mark.feature_complete def test_missing_cap_chown_fails_priming() -> None: """Verify that priming fails when CAP_CHOWN is missing and ownership change is needed."""