mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-03-30 13:23:57 -04:00
adjust tests and allow other users
This commit is contained in:
@@ -221,15 +221,17 @@ RUN addgroup -g ${READONLY_GID} "${READ_ONLY_GROUP}" && \
|
||||
RUN chown -R ${READ_ONLY_USER}:${READ_ONLY_GROUP} ${READ_ONLY_FOLDERS} && \
|
||||
chmod -R 004 ${READ_ONLY_FOLDERS} && \
|
||||
find ${READ_ONLY_FOLDERS} -type d -exec chmod 005 {} + && \
|
||||
install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 700 ${READ_WRITE_FOLDERS} && \
|
||||
chown -R ${NETALERTX_USER}:${NETALERTX_GROUP} ${READ_WRITE_FOLDERS} && \
|
||||
chmod -R 600 ${READ_WRITE_FOLDERS} && \
|
||||
find ${READ_WRITE_FOLDERS} -type d -exec chmod 700 {} + && \
|
||||
install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 0777 ${READ_WRITE_FOLDERS} && \
|
||||
chown ${READ_ONLY_USER}:${READ_ONLY_GROUP} /entrypoint.sh /opt /opt/venv && \
|
||||
chmod 005 /entrypoint.sh ${SYSTEM_SERVICES}/*.sh ${SYSTEM_SERVICES_SCRIPTS}/* ${ENTRYPOINT_CHECKS}/* /app /opt /opt/venv && \
|
||||
for dir in ${READ_WRITE_FOLDERS}; do \
|
||||
install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 700 "$dir"; \
|
||||
done && \
|
||||
# Do not bake first-run artifacts into the image. If present, Docker volume copy-up
|
||||
# will persist restrictive ownership/modes into fresh named volumes, breaking
|
||||
# arbitrary non-root UID/GID runs.
|
||||
rm -f \
|
||||
"${NETALERTX_CONFIG}/app.conf" \
|
||||
"${NETALERTX_DB_FILE}" \
|
||||
"${NETALERTX_DB_FILE}-shm" \
|
||||
"${NETALERTX_DB_FILE}-wal" || true && \
|
||||
apk del apk-tools && \
|
||||
rm -Rf /var /etc/sudoers.d/* /etc/shadow /etc/gshadow /etc/sudoers \
|
||||
/lib/apk /lib/firmware /lib/modules-load.d /lib/sysctl.d /mnt /home/ /root \
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -218,15 +218,17 @@ RUN addgroup -g ${READONLY_GID} "${READ_ONLY_GROUP}" && \
|
||||
RUN chown -R ${READ_ONLY_USER}:${READ_ONLY_GROUP} ${READ_ONLY_FOLDERS} && \
|
||||
chmod -R 004 ${READ_ONLY_FOLDERS} && \
|
||||
find ${READ_ONLY_FOLDERS} -type d -exec chmod 005 {} + && \
|
||||
install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 700 ${READ_WRITE_FOLDERS} && \
|
||||
chown -R ${NETALERTX_USER}:${NETALERTX_GROUP} ${READ_WRITE_FOLDERS} && \
|
||||
chmod -R 600 ${READ_WRITE_FOLDERS} && \
|
||||
find ${READ_WRITE_FOLDERS} -type d -exec chmod 700 {} + && \
|
||||
install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 0777 ${READ_WRITE_FOLDERS} && \
|
||||
chown ${READ_ONLY_USER}:${READ_ONLY_GROUP} /entrypoint.sh /opt /opt/venv && \
|
||||
chmod 005 /entrypoint.sh ${SYSTEM_SERVICES}/*.sh ${SYSTEM_SERVICES_SCRIPTS}/* ${ENTRYPOINT_CHECKS}/* /app /opt /opt/venv && \
|
||||
for dir in ${READ_WRITE_FOLDERS}; do \
|
||||
install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 700 "$dir"; \
|
||||
done && \
|
||||
# Do not bake first-run artifacts into the image. If present, Docker volume copy-up
|
||||
# will persist restrictive ownership/modes into fresh named volumes, breaking
|
||||
# arbitrary non-root UID/GID runs.
|
||||
rm -f \
|
||||
"${NETALERTX_CONFIG}/app.conf" \
|
||||
"${NETALERTX_DB_FILE}" \
|
||||
"${NETALERTX_DB_FILE}-shm" \
|
||||
"${NETALERTX_DB_FILE}-wal" || true && \
|
||||
apk del apk-tools && \
|
||||
rm -Rf /var /etc/sudoers.d/* /etc/shadow /etc/gshadow /etc/sudoers \
|
||||
/lib/apk /lib/firmware /lib/modules-load.d /lib/sysctl.d /mnt /home/ /root \
|
||||
|
||||
@@ -37,7 +37,7 @@ def read_config_file():
|
||||
|
||||
|
||||
configFile = read_config_file()
|
||||
timeZoneSetting = configFile['TIMEZONE']
|
||||
timeZoneSetting = configFile.get('TIMEZONE', default_tz)
|
||||
if timeZoneSetting not in all_timezones:
|
||||
timeZoneSetting = default_tz
|
||||
timeZone = pytz.timezone(timeZoneSetting)
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Mount Diagnostic Tool
|
||||
|
||||
Analyzes container mount points for permission issues, persistence risks, and performance problems.
|
||||
|
||||
TODO: Future Enhancements (Roadmap Step 3 & 4)
|
||||
1. Text-based Output: Replace emoji status indicators (✅, ❌) with plain text (e.g., [OK], [FAIL])
|
||||
to ensure compatibility with all terminal types and logging systems.
|
||||
2. OverlayFS/Copy-up Support: Improve detection logic for filesystems like Synology's OverlayFS
|
||||
where files may appear writable but fail on specific operations (locking, mmap).
|
||||
3. Root-to-User Context: Ensure this tool remains accurate when the container starts as root
|
||||
to fix permissions and then drops privileges to the 'netalertx' user. The check should
|
||||
reflect the *effective* permissions of the application user.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -80,7 +95,21 @@ def _resolve_writeable_state(target_path: str) -> bool:
|
||||
seen.add(current)
|
||||
|
||||
if os.path.exists(current):
|
||||
return os.access(current, os.W_OK)
|
||||
if not os.access(current, os.W_OK):
|
||||
return False
|
||||
|
||||
# OverlayFS/Copy-up check: Try to actually write a file to verify
|
||||
if os.path.isdir(current):
|
||||
test_file = os.path.join(current, f".netalertx_write_test_{os.getpid()}")
|
||||
try:
|
||||
with open(test_file, "w") as f:
|
||||
f.write("test")
|
||||
os.remove(test_file)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
parent_dir = os.path.dirname(current)
|
||||
if not parent_dir or parent_dir == current:
|
||||
|
||||
@@ -7,7 +7,7 @@ if [ ! -f "${NETALERTX_CONFIG}/app.conf" ]; then
|
||||
>&2 echo "ERROR: Failed to create config directory ${NETALERTX_CONFIG}"
|
||||
exit 1
|
||||
}
|
||||
install -m 600 -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} /app/back/app.conf "${NETALERTX_CONFIG}/app.conf" || {
|
||||
install -m 600 /app/back/app.conf "${NETALERTX_CONFIG}/app.conf" || {
|
||||
>&2 echo "ERROR: Failed to deploy default config to ${NETALERTX_CONFIG}/app.conf"
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -13,9 +13,7 @@ mkdir -p "$(dirname "$NETALERTX_CONFIG")" || {
|
||||
rm -f "$OVERRIDE_FILE"
|
||||
|
||||
# Check if APP_CONF_OVERRIDE is set
|
||||
if [ -z "$APP_CONF_OVERRIDE" ]; then
|
||||
>&2 echo "APP_CONF_OVERRIDE is not set. Skipping override config file creation."
|
||||
else
|
||||
if [ -n "$APP_CONF_OVERRIDE" ]; then
|
||||
# Save the APP_CONF_OVERRIDE env variable as a JSON file
|
||||
echo "$APP_CONF_OVERRIDE" > "$OVERRIDE_FILE" || {
|
||||
>&2 echo "ERROR: Failed to write override config to $OVERRIDE_FILE"
|
||||
|
||||
@@ -50,8 +50,7 @@ fi
|
||||
RED='\033[1;31m'
|
||||
GREY='\033[90m'
|
||||
RESET='\033[0m'
|
||||
printf "%s" "${RED}"
|
||||
echo '
|
||||
NAX='
|
||||
_ _ _ ___ _ _ __ __
|
||||
| \ | | | | / _ \| | | | \ \ / /
|
||||
| \| | ___| |_/ /_\ \ | ___ _ __| |_ \ V /
|
||||
@@ -60,13 +59,12 @@ echo '
|
||||
\_| \_/\___|\__\_| |_/_|\___|_| \__\/ \/
|
||||
'
|
||||
|
||||
printf "%s" "${RESET}"
|
||||
printf "%b%s%b" "${RED}" "${NAX}" "${RESET}"
|
||||
echo ' Network intruder and presence detector.
|
||||
https://netalertx.com
|
||||
|
||||
'
|
||||
set -u
|
||||
|
||||
FAILED_STATUS=""
|
||||
echo "Startup pre-checks"
|
||||
for script in "${ENTRYPOINT_CHECKS}"/*; do
|
||||
@@ -123,7 +121,6 @@ fi
|
||||
# Set APP_CONF_OVERRIDE based on GRAPHQL_PORT if not already set
|
||||
if [ -n "${GRAPHQL_PORT:-}" ] && [ -z "${APP_CONF_OVERRIDE:-}" ]; then
|
||||
export APP_CONF_OVERRIDE='{"GRAPHQL_PORT":"'"${GRAPHQL_PORT}"'"}'
|
||||
echo "Setting APP_CONF_OVERRIDE to $APP_CONF_OVERRIDE"
|
||||
fi
|
||||
|
||||
|
||||
@@ -283,15 +280,6 @@ add_service "${SYSTEM_SERVICES}/start-php-fpm.sh" "php-fpm83"
|
||||
add_service "${SYSTEM_SERVICES}/start-nginx.sh" "nginx"
|
||||
add_service "${SYSTEM_SERVICES}/start-backend.sh" "python3"
|
||||
|
||||
################################################################################
|
||||
# Development Mode Debug Switch
|
||||
################################################################################
|
||||
# If NETALERTX_DEBUG=1, skip automatic service restart on failure
|
||||
# Useful for devcontainer debugging where individual services need to be debugged
|
||||
if [ "${NETALERTX_DEBUG:-0}" -eq 1 ]; then
|
||||
echo "NETALERTX_DEBUG is set to 1, will not shut down other services if one fails."
|
||||
fi
|
||||
|
||||
################################################################################
|
||||
# Service Monitoring Loop (Production Mode)
|
||||
################################################################################
|
||||
|
||||
66
test/docker_tests/debug_storage_permission.sh
Executable file
66
test/docker_tests/debug_storage_permission.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
|
||||
# 0-storage-permission.sh: Fix permissions if running as root.
|
||||
#
|
||||
# This script checks if running as root and fixes ownership and permissions
|
||||
# for read-write paths to ensure proper operation.
|
||||
|
||||
# --- Color Codes ---
|
||||
MAGENTA=$(printf '\033[1;35m')
|
||||
RESET=$(printf '\033[0m')
|
||||
|
||||
# --- Main Logic ---
|
||||
|
||||
# Define paths that need read-write access
|
||||
READ_WRITE_PATHS="
|
||||
${NETALERTX_DATA}
|
||||
${NETALERTX_DB}
|
||||
${NETALERTX_API}
|
||||
${NETALERTX_LOG}
|
||||
${SYSTEM_SERVICES_RUN}
|
||||
${NETALERTX_CONFIG}
|
||||
${NETALERTX_CONFIG_FILE}
|
||||
${NETALERTX_DB_FILE}
|
||||
"
|
||||
|
||||
TARGET_USER="${NETALERTX_USER:-netalertx}"
|
||||
|
||||
# If running as root, fix permissions first
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
>&2 printf "%s" "${MAGENTA}"
|
||||
>&2 cat <<'EOF'
|
||||
══════════════════════════════════════════════════════════════════════════════
|
||||
🚨 CRITICAL SECURITY ALERT: NetAlertX is running as ROOT (UID 0)! 🚨
|
||||
|
||||
This configuration bypasses all built-in security hardening measures.
|
||||
You've granted a network monitoring application unrestricted access to
|
||||
your host system. A successful compromise here could jeopardize your
|
||||
entire infrastructure.
|
||||
|
||||
IMMEDIATE ACTION REQUIRED: Switch to the dedicated 'netalertx' user:
|
||||
* Remove any 'user:' directive specifying UID 0 from docker-compose.yml or
|
||||
* switch to the default USER in the image (20211:20211)
|
||||
|
||||
IMPORTANT: This corrective mode automatically adjusts ownership of
|
||||
/data/db and /data/config directories to the netalertx user, ensuring
|
||||
proper operation in subsequent runs.
|
||||
|
||||
Remember: Never operate security-critical tools as root unless you're
|
||||
actively trying to get pwned.
|
||||
|
||||
https://github.com/jokob-sk/NetAlertX/blob/main/docs/docker-troubleshooting/running-as-root.md
|
||||
══════════════════════════════════════════════════════════════════════════════
|
||||
EOF
|
||||
>&2 printf "%s" "${RESET}"
|
||||
|
||||
# Set ownership and permissions for each read-write path individually
|
||||
printf '%s\n' "${READ_WRITE_PATHS}" | while IFS= read -r path; do
|
||||
[ -n "${path}" ] || continue
|
||||
echo "DEBUG: Processing $path"
|
||||
chown -v -R "${TARGET_USER}" "${path}" || echo "DEBUG: chown failed for $path"
|
||||
find "${path}" -type d -exec chmod -v u+rwx {} \;
|
||||
find "${path}" -type f -exec chmod -v u+rw {} \;
|
||||
done
|
||||
echo Permissions fixed for read-write paths. Please restart the container as user ${TARGET_USER}.
|
||||
sleep infinity & wait $!
|
||||
fi
|
||||
@@ -45,40 +45,91 @@ def _unique_label(prefix: str) -> str:
|
||||
return f"{prefix.upper()}__NETALERTX_INTENTIONAL__{uuid.uuid4().hex[:6]}"
|
||||
|
||||
|
||||
def test_nonroot_custom_uid_logs_note(
|
||||
tmp_path: pathlib.Path,
|
||||
uid_gid: tuple[int, int],
|
||||
) -> None:
|
||||
def _repo_root() -> pathlib.Path:
|
||||
env = os.environ.get("NETALERTX_REPO_ROOT")
|
||||
if env:
|
||||
return pathlib.Path(env)
|
||||
cur = pathlib.Path(__file__).resolve()
|
||||
for parent in cur.parents:
|
||||
if any(
|
||||
[
|
||||
(parent / "pyproject.toml").exists(),
|
||||
(parent / ".git").exists(),
|
||||
(parent / "back").exists() and (parent / "db").exists(),
|
||||
]
|
||||
):
|
||||
return parent
|
||||
return cur.parents[2]
|
||||
|
||||
|
||||
def _docker_visible_tmp_root() -> pathlib.Path:
|
||||
"""Return a docker-daemon-visible scratch directory for bind mounts.
|
||||
|
||||
Pytest's default tmp_path lives under /tmp inside the devcontainer, which may
|
||||
not be visible to the Docker daemon that evaluates bind mount source paths.
|
||||
We use /tmp/pytest-docker-mounts instead of the repo.
|
||||
"""
|
||||
|
||||
root = pathlib.Path("/tmp/pytest-docker-mounts")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
root.chmod(0o777)
|
||||
except PermissionError:
|
||||
# Best-effort; the directory only needs to be writable by the current user.
|
||||
pass
|
||||
return root
|
||||
|
||||
|
||||
def _docker_visible_path(path: pathlib.Path) -> pathlib.Path:
|
||||
"""Map a path into `_docker_visible_tmp_root()` when it lives under /tmp."""
|
||||
|
||||
try:
|
||||
if str(path).startswith("/tmp/"):
|
||||
return _docker_visible_tmp_root() / path.name
|
||||
except Exception:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def _setup_mount_tree(
|
||||
tmp_path: pathlib.Path,
|
||||
prefix: str,
|
||||
paths = _setup_mount_tree(tmp_path, f"note_uid_{uid}")
|
||||
for key in ["data", "app_db", "app_config"]:
|
||||
paths[key].chmod(0o777)
|
||||
volumes = _build_volume_args_for_keys(paths, {"data"})
|
||||
result = _run_container(
|
||||
f"note-uid-{uid}",
|
||||
volumes,
|
||||
user=f"{uid}:{gid}",
|
||||
sleep_seconds=5,
|
||||
)
|
||||
_assert_contains(result, f"NetAlertX note: current UID {uid} GID {gid}", result.args)
|
||||
assert "expected UID" in result.output
|
||||
assert result.returncode == 0
|
||||
*,
|
||||
seed_config: bool = True,
|
||||
seed_db: bool = True,
|
||||
) -> dict[str, pathlib.Path]:
|
||||
"""Create a compose-like host tree with permissive perms for arbitrary UID/GID."""
|
||||
|
||||
label = _unique_label(prefix)
|
||||
base = _docker_visible_tmp_root() / f"{label}_MOUNT_ROOT"
|
||||
base.mkdir()
|
||||
base.chmod(0o777)
|
||||
|
||||
paths: dict[str, pathlib.Path] = {}
|
||||
|
||||
data_root = base / f"{label}_DATA_INTENTIONAL_NETALERTX_TEST"
|
||||
data_root.mkdir(parents=True, exist_ok=True)
|
||||
data_root.chmod(0o777)
|
||||
paths["data"] = data_root
|
||||
|
||||
db_dir = data_root / "db"
|
||||
db_dir.mkdir(exist_ok=True)
|
||||
db_dir.chmod(0o777)
|
||||
paths["app_db"] = db_dir
|
||||
paths["data_db"] = db_dir
|
||||
|
||||
config_dir = data_root / "config"
|
||||
config_dir.mkdir(exist_ok=True)
|
||||
config_dir.chmod(0o777)
|
||||
paths["app_config"] = config_dir
|
||||
paths["data_config"] = config_dir
|
||||
|
||||
# Optional /tmp mounts that certain tests intentionally bind
|
||||
for key in OPTIONAL_TMP_KEYS:
|
||||
folder_name = f"{label}_{key.upper()}_INTENTIONAL_NETALERTX_TEST"
|
||||
host_path = base / folder_name
|
||||
host_path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
host_path.chmod(0o777)
|
||||
except PermissionError:
|
||||
pass
|
||||
host_path.chmod(0o777)
|
||||
paths[key] = host_path
|
||||
# Provide backwards-compatible aliases where helpful
|
||||
if key == "app_log":
|
||||
paths["log"] = host_path
|
||||
elif key == "app_api":
|
||||
@@ -86,54 +137,45 @@ def _setup_mount_tree(
|
||||
elif key == "nginx_conf":
|
||||
paths["nginx_active"] = host_path
|
||||
|
||||
# Determine repo root from env or by walking up from this file
|
||||
repo_root_env = os.environ.get("NETALERTX_REPO_ROOT")
|
||||
if repo_root_env:
|
||||
repo_root = pathlib.Path(repo_root_env)
|
||||
else:
|
||||
repo_root = None
|
||||
cur = pathlib.Path(__file__).resolve()
|
||||
for parent in cur.parents:
|
||||
if any([
|
||||
(parent / "pyproject.toml").exists(),
|
||||
(parent / ".git").exists(),
|
||||
(parent / "back").exists() and (parent / "db").exists()
|
||||
]):
|
||||
repo_root = parent
|
||||
break
|
||||
if repo_root is None:
|
||||
repo_root = cur.parents[2]
|
||||
|
||||
repo_root = _repo_root()
|
||||
if seed_config:
|
||||
config_file = paths["app_config"] / "app.conf"
|
||||
config_src = repo_root / "back" / "app.conf"
|
||||
if not config_src.exists():
|
||||
print(
|
||||
f"[WARN] Seed file not found: {config_src}. Set NETALERTX_REPO_ROOT or run from repo root. Skipping copy."
|
||||
)
|
||||
else:
|
||||
shutil.copyfile(config_src, config_file)
|
||||
config_file.chmod(0o600)
|
||||
config_dst = paths["app_config"] / "app.conf"
|
||||
if config_src.exists():
|
||||
shutil.copyfile(config_src, config_dst)
|
||||
config_dst.chmod(0o666)
|
||||
if seed_db:
|
||||
db_file = paths["app_db"] / "app.db"
|
||||
db_src = repo_root / "db" / "app.db"
|
||||
if not db_src.exists():
|
||||
print(
|
||||
f"[WARN] Seed file not found: {db_src}. Set NETALERTX_REPO_ROOT or run from repo root. Skipping copy."
|
||||
)
|
||||
else:
|
||||
shutil.copyfile(db_src, db_file)
|
||||
db_file.chmod(0o600)
|
||||
db_dst = paths["app_db"] / "app.db"
|
||||
if db_src.exists():
|
||||
shutil.copyfile(db_src, db_dst)
|
||||
db_dst.chmod(0o666)
|
||||
|
||||
_chown_netalertx(base)
|
||||
# Ensure every mount point is world-writable so arbitrary UID/GID can write
|
||||
for p in paths.values():
|
||||
if p.is_dir():
|
||||
p.chmod(0o777)
|
||||
for child in p.iterdir():
|
||||
if child.is_dir():
|
||||
child.chmod(0o777)
|
||||
else:
|
||||
child.chmod(0o666)
|
||||
else:
|
||||
p.chmod(0o666)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def _setup_fixed_mount_tree(base: pathlib.Path) -> dict[str, pathlib.Path]:
|
||||
base = _docker_visible_path(base)
|
||||
|
||||
if base.exists():
|
||||
shutil.rmtree(base)
|
||||
base.mkdir(parents=True)
|
||||
try:
|
||||
base.chmod(0o777)
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
paths: dict[str, pathlib.Path] = {}
|
||||
|
||||
@@ -191,6 +233,41 @@ def _build_volume_args_for_keys(
|
||||
return bindings
|
||||
|
||||
|
||||
def _chown_path(host_path: pathlib.Path, uid: int, gid: int) -> None:
|
||||
"""Chown a host path using the test image with host user namespace."""
|
||||
|
||||
if not host_path.exists():
|
||||
raise RuntimeError(f"Cannot chown missing path {host_path}")
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--userns",
|
||||
"host",
|
||||
"--user",
|
||||
"0:0",
|
||||
"--entrypoint",
|
||||
"/bin/chown",
|
||||
"-v",
|
||||
f"{host_path}:/mnt",
|
||||
IMAGE,
|
||||
"-R",
|
||||
f"{uid}:{gid}",
|
||||
"/mnt",
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(f"Failed to chown {host_path} to {uid}:{gid}") from exc
|
||||
|
||||
|
||||
def _chown_root(host_path: pathlib.Path) -> None:
|
||||
_chown_path(host_path, 0, 0)
|
||||
|
||||
@@ -199,6 +276,161 @@ def _chown_netalertx(host_path: pathlib.Path) -> None:
|
||||
_chown_path(host_path, 20211, 20211)
|
||||
|
||||
|
||||
def _docker_volume_rm(volume_name: str) -> None:
|
||||
subprocess.run(
|
||||
["docker", "volume", "rm", "-f", volume_name],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def _docker_volume_create(volume_name: str) -> None:
|
||||
subprocess.run(
|
||||
["docker", "volume", "create", volume_name],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def _fresh_named_volume(prefix: str) -> str:
|
||||
name = _unique_label(prefix).lower().replace("__", "-")
|
||||
# Ensure we're exercising Docker's fresh-volume copy-up behavior.
|
||||
_docker_volume_rm(name)
|
||||
return name
|
||||
|
||||
|
||||
def _ensure_volume_copy_up(volume_name: str) -> None:
|
||||
"""Ensure a named volume is initialized from the NetAlertX image.
|
||||
|
||||
If we write into the volume first (e.g., with an Alpine helper container),
|
||||
Docker will not perform the image-to-volume copy-up and the volume root may
|
||||
stay root:root 0755, breaking arbitrary UID/GID runs.
|
||||
"""
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--userns",
|
||||
"host",
|
||||
"-v",
|
||||
f"{volume_name}:/data",
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
IMAGE,
|
||||
"-c",
|
||||
"true",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def _seed_volume_text_file(
|
||||
volume_name: str,
|
||||
container_path: str,
|
||||
content: str,
|
||||
*,
|
||||
chmod_mode: str = "644",
|
||||
user: str | None = None,
|
||||
) -> None:
|
||||
"""Create/overwrite a text file inside a named volume.
|
||||
|
||||
Uses a tiny helper container so we don't rely on bind mounts (which are
|
||||
resolved on the Docker daemon host).
|
||||
"""
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--userns",
|
||||
"host",
|
||||
]
|
||||
if user:
|
||||
cmd.extend(["--user", user])
|
||||
cmd.extend(
|
||||
[
|
||||
"-v",
|
||||
f"{volume_name}:/data",
|
||||
"alpine:3.22",
|
||||
"sh",
|
||||
"-c",
|
||||
f"set -eu; mkdir -p \"$(dirname '{container_path}')\"; cat > '{container_path}'; chmod {chmod_mode} '{container_path}'",
|
||||
]
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
cmd,
|
||||
input=content,
|
||||
text=True,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def _volume_has_file(volume_name: str, container_path: str) -> bool:
|
||||
return (
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--userns",
|
||||
"host",
|
||||
"-v",
|
||||
f"{volume_name}:/data",
|
||||
"alpine:3.22",
|
||||
"sh",
|
||||
"-c",
|
||||
f"test -f '{container_path}'",
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"uid_gid",
|
||||
[
|
||||
(1001, 1001),
|
||||
(1502, 1502),
|
||||
],
|
||||
)
|
||||
def test_nonroot_custom_uid_logs_note(
|
||||
tmp_path: pathlib.Path,
|
||||
uid_gid: tuple[int, int],
|
||||
) -> None:
|
||||
"""Ensure arbitrary non-root UID/GID can run with compose-like mounts."""
|
||||
|
||||
uid, gid = uid_gid
|
||||
|
||||
vol = _fresh_named_volume(f"note_uid_{uid}")
|
||||
try:
|
||||
# Fresh named volume at /data: matches default docker-compose UX.
|
||||
result = _run_container(
|
||||
f"note-uid-{uid}",
|
||||
volumes=None,
|
||||
volume_specs=[f"{vol}:/data"],
|
||||
user=f"{uid}:{gid}",
|
||||
sleep_seconds=5,
|
||||
)
|
||||
finally:
|
||||
_docker_volume_rm(vol)
|
||||
|
||||
_assert_contains(result, f"NetAlertX note: current UID {uid} GID {gid}", result.args)
|
||||
assert "expected UID" in result.output
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
def _run_container(
|
||||
label: str,
|
||||
volumes: list[tuple[str, str, bool]] | None = None,
|
||||
@@ -212,9 +444,23 @@ def _run_container(
|
||||
sleep_seconds: float = GRACE_SECONDS,
|
||||
wait_for_exit: bool = False,
|
||||
pre_entrypoint: str | None = None,
|
||||
userns_mode: str | None = "host",
|
||||
image: str = IMAGE,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
name = f"netalertx-test-{label}-{uuid.uuid4().hex[:8]}".lower()
|
||||
|
||||
tmp_uid = 20211
|
||||
tmp_gid = 20211
|
||||
if user:
|
||||
try:
|
||||
u_str, g_str = user.split(":", 1)
|
||||
tmp_uid = int(u_str)
|
||||
tmp_gid = int(g_str)
|
||||
except Exception:
|
||||
# Keep defaults if user format is unexpected.
|
||||
tmp_uid = 20211
|
||||
tmp_gid = 20211
|
||||
|
||||
# Clean up any existing container with this name
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
@@ -225,21 +471,35 @@ def _run_container(
|
||||
|
||||
cmd: list[str] = ["docker", "run", "--rm", "--name", name]
|
||||
|
||||
# Avoid flakiness in host-network runs when the host already uses the
|
||||
# default NetAlertX ports. Tests can still override explicitly via `env`.
|
||||
effective_env: dict[str, str] = dict(env or {})
|
||||
if network_mode == "host":
|
||||
if "PORT" not in effective_env:
|
||||
effective_env["PORT"] = str(30000 + (int(uuid.uuid4().hex[:4], 16) % 20000))
|
||||
if "GRAPHQL_PORT" not in effective_env:
|
||||
gql = 30000 + (int(uuid.uuid4().hex[4:8], 16) % 20000)
|
||||
if str(gql) == effective_env["PORT"]:
|
||||
gql = 30000 + ((gql + 1) % 20000)
|
||||
effective_env["GRAPHQL_PORT"] = str(gql)
|
||||
|
||||
if network_mode:
|
||||
cmd.extend(["--network", network_mode])
|
||||
cmd.extend(["--userns", "host"])
|
||||
# Add default ramdisk to /tmp with permissions 777
|
||||
cmd.extend(["--tmpfs", "/tmp:mode=777"])
|
||||
if userns_mode:
|
||||
cmd.extend(["--userns", userns_mode])
|
||||
# Match docker-compose UX: /tmp is tmpfs with 1700 and owned by the runtime UID/GID.
|
||||
cmd.extend(["--tmpfs", f"/tmp:mode=1700,uid={tmp_uid},gid={tmp_gid}"])
|
||||
if user:
|
||||
cmd.extend(["--user", user])
|
||||
if drop_caps:
|
||||
if drop_caps is not None:
|
||||
for cap in drop_caps:
|
||||
cmd.extend(["--cap-drop", cap])
|
||||
else:
|
||||
cmd.extend(["--cap-drop", "ALL"])
|
||||
for cap in DEFAULT_CAPS:
|
||||
cmd.extend(["--cap-add", cap])
|
||||
if env:
|
||||
for key, value in env.items():
|
||||
if effective_env:
|
||||
for key, value in effective_env.items():
|
||||
cmd.extend(["-e", f"{key}={value}"])
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
@@ -280,7 +540,7 @@ def _run_container(
|
||||
"if kill -0 $pid >/dev/null 2>&1; then kill -TERM $pid >/dev/null 2>&1 || true; fi; ",
|
||||
"wait $pid; code=$?; if [ $code -eq 143 ]; then exit 0; fi; exit $code"
|
||||
])
|
||||
cmd.extend(["--entrypoint", "/bin/sh", IMAGE, "-c", script])
|
||||
cmd.extend(["--entrypoint", "/bin/sh", image, "-c", script])
|
||||
|
||||
# Print the full Docker command for debugging
|
||||
print("\n--- DOCKER CMD ---\n", " ".join(cmd), "\n--- END CMD ---\n")
|
||||
@@ -456,14 +716,17 @@ def test_missing_host_network_warns(tmp_path: pathlib.Path) -> None:
|
||||
Check script: check-network-mode.sh
|
||||
Sample message: "⚠️ ATTENTION: NetAlertX is not running with --network=host. Bridge networking..."
|
||||
"""
|
||||
paths = _setup_mount_tree(tmp_path, "missing_host_network")
|
||||
volumes = _build_volume_args_for_keys(paths, {"data"})
|
||||
result = _run_container(
|
||||
"missing-host-network",
|
||||
volumes,
|
||||
network_mode=None,
|
||||
sleep_seconds=5,
|
||||
)
|
||||
vol = _fresh_named_volume("missing_host_network")
|
||||
try:
|
||||
result = _run_container(
|
||||
"missing-host-network",
|
||||
volumes=None,
|
||||
volume_specs=[f"{vol}:/data"],
|
||||
network_mode=None,
|
||||
sleep_seconds=15,
|
||||
)
|
||||
finally:
|
||||
_docker_volume_rm(vol)
|
||||
_assert_contains(result, "not running with --network=host", result.args)
|
||||
|
||||
|
||||
@@ -485,14 +748,16 @@ def test_missing_app_conf_triggers_seed(tmp_path: pathlib.Path) -> None:
|
||||
Check script: /entrypoint.d/15-first-run-config.sh
|
||||
Sample message: "Default configuration written to"
|
||||
"""
|
||||
base = tmp_path / "missing_app_conf_base"
|
||||
paths = _setup_fixed_mount_tree(base)
|
||||
for key in ["data", "app_db", "app_config"]:
|
||||
paths[key].chmod(0o777)
|
||||
_chown_netalertx(paths[key])
|
||||
(paths["app_config"] / "testfile.txt").write_text("test")
|
||||
volumes = _build_volume_args_for_keys(paths, {"data"})
|
||||
result = _run_container("missing-app-conf", volumes, sleep_seconds=5)
|
||||
vol = _fresh_named_volume("missing_app_conf")
|
||||
try:
|
||||
result = _run_container(
|
||||
"missing-app-conf",
|
||||
volumes=None,
|
||||
volume_specs=[f"{vol}:/data"],
|
||||
sleep_seconds=15,
|
||||
)
|
||||
finally:
|
||||
_docker_volume_rm(vol)
|
||||
_assert_contains(result, "Default configuration written to", result.args)
|
||||
assert result.returncode == 0
|
||||
|
||||
@@ -507,20 +772,28 @@ def test_missing_app_db_triggers_seed(tmp_path: pathlib.Path) -> None:
|
||||
Check script: /entrypoint.d/20-first-run-db.sh
|
||||
Sample message: "Building initial database schema"
|
||||
"""
|
||||
paths = _setup_mount_tree(tmp_path, "missing_app_db", seed_db=False)
|
||||
config_file = paths["app_config"] / "app.conf"
|
||||
config_file.write_text("TIMEZONE='UTC'\n")
|
||||
config_file.chmod(0o600)
|
||||
volumes = _build_volume_args_for_keys(paths, {"data"})
|
||||
result = _run_container(
|
||||
"missing-app-db",
|
||||
volumes,
|
||||
user="20211:20211",
|
||||
sleep_seconds=5,
|
||||
wait_for_exit=True,
|
||||
)
|
||||
assert (paths["app_db"] / "app.db").exists()
|
||||
assert result.returncode != 0
|
||||
vol = _fresh_named_volume("missing_app_db")
|
||||
try:
|
||||
_ensure_volume_copy_up(vol)
|
||||
# Seed only app.conf; leave app.db missing to trigger first-run DB schema creation.
|
||||
_seed_volume_text_file(
|
||||
vol,
|
||||
"/data/config/app.conf",
|
||||
"TIMEZONE='UTC'\n",
|
||||
chmod_mode="644",
|
||||
user="20211:20211",
|
||||
)
|
||||
result = _run_container(
|
||||
"missing-app-db",
|
||||
volumes=None,
|
||||
volume_specs=[f"{vol}:/data"],
|
||||
user="20211:20211",
|
||||
sleep_seconds=20,
|
||||
)
|
||||
assert _volume_has_file(vol, "/data/db/app.db")
|
||||
finally:
|
||||
_docker_volume_rm(vol)
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
def test_custom_port_without_writable_conf(tmp_path: pathlib.Path) -> None:
|
||||
@@ -534,26 +807,23 @@ def test_custom_port_without_writable_conf(tmp_path: pathlib.Path) -> None:
|
||||
Sample messages: "⚠️ ATTENTION: Nginx configuration mount /tmp/nginx/active-config is missing."
|
||||
"⚠️ ATTENTION: Unable to write to /tmp/nginx/active-config/netalertx.conf."
|
||||
"""
|
||||
paths = _setup_mount_tree(tmp_path, "custom_port_ro_conf")
|
||||
for key in ["data", "app_db", "app_config", "app_log", "app_api", "services_run"]:
|
||||
paths[key].chmod(0o777)
|
||||
volumes = _build_volume_args_for_keys(paths, {"data"})
|
||||
volumes += _build_volume_args_for_keys(
|
||||
paths,
|
||||
{"app_log", "app_api", "services_run"},
|
||||
)
|
||||
vol = _fresh_named_volume("custom_port_ro_conf")
|
||||
extra_args = [
|
||||
"--tmpfs",
|
||||
f"{VOLUME_MAP['nginx_conf']}:uid=20211,gid=20211,mode=500",
|
||||
]
|
||||
result = _run_container(
|
||||
"custom-port-ro-conf",
|
||||
volumes,
|
||||
env={"PORT": "24444", "LISTEN_ADDR": "127.0.0.1"},
|
||||
user="20211:20211",
|
||||
extra_args=extra_args,
|
||||
sleep_seconds=5,
|
||||
)
|
||||
try:
|
||||
result = _run_container(
|
||||
"custom-port-ro-conf",
|
||||
volumes=None,
|
||||
volume_specs=[f"{vol}:/data"],
|
||||
env={"PORT": "24444", "LISTEN_ADDR": "127.0.0.1"},
|
||||
user="20211:20211",
|
||||
extra_args=extra_args,
|
||||
sleep_seconds=15,
|
||||
)
|
||||
finally:
|
||||
_docker_volume_rm(vol)
|
||||
_assert_contains(result, "Unable to write to", result.args)
|
||||
_assert_contains(
|
||||
result, f"{VOLUME_MAP['nginx_conf']}/netalertx.conf", result.args
|
||||
@@ -570,14 +840,17 @@ def test_excessive_capabilities_warning(tmp_path: pathlib.Path) -> None:
|
||||
Check script: 90-excessive-capabilities.sh
|
||||
Sample message: "Excessive capabilities detected"
|
||||
"""
|
||||
paths = _setup_mount_tree(tmp_path, "excessive_caps")
|
||||
volumes = _build_volume_args_for_keys(paths, {"data"})
|
||||
result = _run_container(
|
||||
"excessive-caps",
|
||||
volumes,
|
||||
extra_args=["--cap-add=SYS_ADMIN", "--cap-add=NET_BROADCAST"],
|
||||
sleep_seconds=5,
|
||||
)
|
||||
vol = _fresh_named_volume("excessive_caps")
|
||||
try:
|
||||
result = _run_container(
|
||||
"excessive-caps",
|
||||
volumes=None,
|
||||
volume_specs=[f"{vol}:/data"],
|
||||
extra_args=["--cap-add=SYS_ADMIN", "--cap-add=NET_BROADCAST"],
|
||||
sleep_seconds=15,
|
||||
)
|
||||
finally:
|
||||
_docker_volume_rm(vol)
|
||||
_assert_contains(result, "Excessive capabilities detected", result.args)
|
||||
_assert_contains(result, "bounding caps:", result.args)
|
||||
|
||||
@@ -591,13 +864,16 @@ def test_appliance_integrity_read_write_mode(tmp_path: pathlib.Path) -> None:
|
||||
Check script: 95-appliance-integrity.sh
|
||||
Sample message: "Container is running as read-write, not in read-only mode"
|
||||
"""
|
||||
paths = _setup_mount_tree(tmp_path, "appliance_integrity")
|
||||
volumes = _build_volume_args_for_keys(paths, {"data"})
|
||||
result = _run_container(
|
||||
"appliance-integrity",
|
||||
volumes,
|
||||
sleep_seconds=5,
|
||||
)
|
||||
vol = _fresh_named_volume("appliance_integrity")
|
||||
try:
|
||||
result = _run_container(
|
||||
"appliance-integrity",
|
||||
volumes=None,
|
||||
volume_specs=[f"{vol}:/data"],
|
||||
sleep_seconds=15,
|
||||
)
|
||||
finally:
|
||||
_docker_volume_rm(vol)
|
||||
_assert_contains(
|
||||
result, "Container is running as read-write, not in read-only mode", result.args
|
||||
)
|
||||
@@ -830,3 +1106,84 @@ def test_mount_analysis_dataloss_risk(tmp_path: pathlib.Path) -> None:
|
||||
# Check that configuration issues are detected due to dataloss risk
|
||||
_assert_contains(result, "Configuration issues detected", result.args)
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
def test_restrictive_permissions_handling(tmp_path: pathlib.Path) -> None:
|
||||
"""Test handling of restrictive permissions on bind mounts.
|
||||
|
||||
Simulates a user mounting a directory with restrictive permissions (e.g., 755 root:root).
|
||||
The container should either fail gracefully or handle it if running as root (which triggers fix).
|
||||
If running as non-root (default), it should fail to write if it doesn't have access.
|
||||
"""
|
||||
paths = _setup_mount_tree(tmp_path, "restrictive_perms")
|
||||
|
||||
# Helper to chown without userns host (workaround for potential devcontainer hang)
|
||||
def _chown_root_safe(host_path: pathlib.Path) -> None:
|
||||
cmd = [
|
||||
"docker", "run", "--rm",
|
||||
# "--userns", "host", # Removed to avoid hang
|
||||
"--user", "0:0",
|
||||
"--entrypoint", "/bin/chown",
|
||||
"-v", f"{host_path}:/mnt",
|
||||
IMAGE,
|
||||
"-R", "0:0", "/mnt",
|
||||
]
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
# Set up a restrictive directory (root owned, 755)
|
||||
target_dir = paths["app_db"]
|
||||
_chown_root_safe(target_dir)
|
||||
target_dir.chmod(0o755)
|
||||
|
||||
# Mount ALL volumes to avoid 'find' errors in 0-storage-permission.sh
|
||||
keys = {"data", "app_db", "app_config", "app_log", "app_api", "services_run", "nginx_conf"}
|
||||
volumes = _build_volume_args_for_keys(paths, keys)
|
||||
|
||||
# Case 1: Running as non-root (default) - Should fail to write
|
||||
# We disable host network/userns to avoid potential hangs in devcontainer environment
|
||||
result = _run_container(
|
||||
"restrictive-perms-user",
|
||||
volumes,
|
||||
user="20211:20211",
|
||||
sleep_seconds=5,
|
||||
network_mode=None,
|
||||
userns_mode=None
|
||||
)
|
||||
assert result.returncode != 0 or "Permission denied" in result.output or "Unable to write" in result.output
|
||||
|
||||
# Case 2: Running as root - Should trigger the fix script
|
||||
result_root = _run_container(
|
||||
"restrictive-perms-root",
|
||||
volumes,
|
||||
user="0:0",
|
||||
sleep_seconds=5,
|
||||
network_mode=None,
|
||||
userns_mode=None
|
||||
)
|
||||
|
||||
_assert_contains(result_root, "NetAlertX is running as ROOT", result_root.args)
|
||||
_assert_contains(result_root, "Permissions fixed for read-write paths", result_root.args)
|
||||
|
||||
# Verify the fix actually happened
|
||||
data_host_path = paths["data"]
|
||||
|
||||
check_cmd = [
|
||||
"docker", "run", "--rm",
|
||||
"--entrypoint", "/bin/sh",
|
||||
"--user", "20211:20211",
|
||||
IMAGE,
|
||||
"-c", "ls -ldn /data/db && touch /data/db/test_write_after_fix"
|
||||
]
|
||||
# Add all volumes to check_cmd too
|
||||
for host_path, target, readonly in volumes:
|
||||
check_cmd.extend(["-v", f"{host_path}:{target}"])
|
||||
|
||||
check_result = subprocess.run(check_cmd, capture_output=True, text=True)
|
||||
|
||||
if check_result.returncode != 0:
|
||||
print(f"Check command failed. Cmd: {check_cmd}")
|
||||
print(f"Stderr: {check_result.stderr}")
|
||||
print(f"Stdout: {check_result.stdout}")
|
||||
|
||||
assert check_result.returncode == 0, f"Should be able to write after root fix script runs. Stderr: {check_result.stderr}. Stdout: {check_result.stdout}"
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ def test_skip_tests_env_var():
|
||||
@pytest.mark.feature_complete
|
||||
def test_app_conf_override_from_graphql_port():
|
||||
# If GRAPHQL_PORT is set and APP_CONF_OVERRIDE is not set, the entrypoint should set
|
||||
# APP_CONF_OVERRIDE to a JSON string containing the GRAPHQL_PORT value and print a message
|
||||
# about it.
|
||||
# APP_CONF_OVERRIDE to a JSON string containing the GRAPHQL_PORT value.
|
||||
# The script should exit successfully.
|
||||
result = _run_entrypoint(env={"GRAPHQL_PORT": "20212", "SKIP_TESTS": "1"}, check_only=True)
|
||||
assert 'Setting APP_CONF_OVERRIDE to {"GRAPHQL_PORT":"20212"}' in result.stdout
|
||||
assert 'Setting APP_CONF_OVERRIDE to' not in result.stdout
|
||||
assert 'APP_CONF_OVERRIDE detected' in result.stderr
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ Pytest-based Mount Diagnostic Tests for NetAlertX
|
||||
Tests all possible mount configurations for each path to validate the diagnostic tool.
|
||||
Uses pytest framework for proper test discovery and execution.
|
||||
|
||||
TODO: Future Robustness & Compatibility Tests
|
||||
1. Symlink Attacks: Verify behavior when a writable directory is mounted via a symlink.
|
||||
Hypothesis: The tool might misidentify the mount status or path.
|
||||
2. OverlayFS/Copy-up Scenarios: Investigate behavior on filesystems like Synology's OverlayFS.
|
||||
Hypothesis: Files might appear writable but fail on specific operations (locking, mmap).
|
||||
3. Text-based Output: Refactor output to support text-based status (e.g., [OK], [FAIL])
|
||||
instead of emojis for better compatibility with terminals that don't support unicode.
|
||||
|
||||
All tests use the mounts table. For reference, the mounts table looks like this:
|
||||
|
||||
Path | Writeable | Mount | RAMDisk | Performance | DataLoss
|
||||
@@ -604,3 +612,4 @@ def test_table_parsing():
|
||||
performance=True,
|
||||
dataloss=True,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user