mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
Compare commits
2
Commits
v3.5.0
...
00cbbb6ec0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cbbb6ec0 | ||
|
|
fb90349ae3 |
@@ -157,5 +157,6 @@ For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TR
|
|||||||
- Store installs: `src/plugin_system/store_manager.py` (`_install_dependencies`)
|
- Store installs: `src/plugin_system/store_manager.py` (`_install_dependencies`)
|
||||||
- Root install helper: `src/common/permission_utils.py` (`install_requirements_file`), `scripts/fix_perms/safe_pip_install.sh`
|
- Root install helper: `src/common/permission_utils.py` (`install_requirements_file`), `scripts/fix_perms/safe_pip_install.sh`
|
||||||
- Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`)
|
- Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`)
|
||||||
- Sudo rules: `scripts/install/configure_web_sudo.sh`
|
- Sudo rules: `scripts/install/lib_sudoers.sh` (written by `first_time_install.sh`
|
||||||
|
and `scripts/install/configure_web_sudo.sh`)
|
||||||
- Manual installer: `scripts/install_plugin_dependencies.sh`
|
- Manual installer: `scripts/install_plugin_dependencies.sh`
|
||||||
|
|||||||
+58
-113
@@ -502,6 +502,41 @@ print_rgbmatrix_build_failure() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Set WEB_SERVICE_USER to the account ledmatrix-web.service runs as, or "root"
|
||||||
|
# when it cannot tell. Steps 3.1 and 11 choose plugin-directory ownership from
|
||||||
|
# it. The logic was pasted three times, identically, and is kept verbatim here.
|
||||||
|
# Note: install_web_service.sh and install_service.sh no longer contain the
|
||||||
|
# "User=root" / "User=${ACTUAL_USER}" strings grepped for below (the units come
|
||||||
|
# from systemd/*.service templates with User=__USER__), so until Step 8 has
|
||||||
|
# installed the unit this yields "root".
|
||||||
|
detect_web_service_user() {
|
||||||
|
WEB_SERVICE_USER="root"
|
||||||
|
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||||
|
# Check actual installed service file (most accurate)
|
||||||
|
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
|
||||||
|
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
||||||
|
# Check install_web_service.sh (used by first_time_install.sh)
|
||||||
|
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||||
|
WEB_SERVICE_USER="root"
|
||||||
|
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||||
|
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||||
|
fi
|
||||||
|
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
||||||
|
# Check template file (may have placeholder)
|
||||||
|
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
|
||||||
|
# If template has placeholder, check install script
|
||||||
|
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
|
||||||
|
# Check install_service.sh to see what user it uses
|
||||||
|
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||||
|
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||||
|
# Web service will be installed by install_service.sh as ACTUAL_USER
|
||||||
|
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "This script will perform the following steps:"
|
echo "This script will perform the following steps:"
|
||||||
echo "1. Check prerequisites (network, disk, memory) and install system dependencies"
|
echo "1. Check prerequisites (network, disk, memory) and install system dependencies"
|
||||||
@@ -699,32 +734,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Determine ownership based on web service user
|
# Determine ownership based on web service user
|
||||||
# Check if web service file exists and what user it runs as
|
detect_web_service_user
|
||||||
WEB_SERVICE_USER="root"
|
|
||||||
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
|
||||||
# Check actual installed service file (most accurate)
|
|
||||||
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
|
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
|
||||||
# Check install_web_service.sh (used by first_time_install.sh)
|
|
||||||
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="root"
|
|
||||||
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
|
||||||
# Check template file (may have placeholder)
|
|
||||||
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
|
|
||||||
# If template has placeholder, check install script
|
|
||||||
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
|
|
||||||
# Check install_service.sh to see what user it uses
|
|
||||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
|
||||||
# Web service will be installed by install_service.sh as ACTUAL_USER
|
|
||||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
|
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
|
||||||
# so the web service can change permissions. Root service can still access via group (775).
|
# so the web service can change permissions. Root service can still access via group (775).
|
||||||
@@ -758,32 +768,7 @@ if [ ! -d "$PLUGIN_REPOS_DIR" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Determine ownership based on web service user
|
# Determine ownership based on web service user
|
||||||
# Check if web service file exists and what user it runs as
|
detect_web_service_user
|
||||||
WEB_SERVICE_USER="root"
|
|
||||||
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
|
||||||
# Check actual installed service file (most accurate)
|
|
||||||
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
|
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
|
||||||
# Check install_web_service.sh (used by first_time_install.sh)
|
|
||||||
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="root"
|
|
||||||
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
|
||||||
# Check template file (may have placeholder)
|
|
||||||
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
|
|
||||||
# If template has placeholder, check install script
|
|
||||||
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
|
|
||||||
# Check install_service.sh to see what user it uses
|
|
||||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
|
||||||
# Web service will be installed by install_service.sh as ACTUAL_USER
|
|
||||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
|
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
|
||||||
# so the web service can change permissions. Root service can still access via group (775).
|
# so the web service can change permissions. Root service can still access via group (775).
|
||||||
@@ -1516,51 +1501,31 @@ POWEROFF_PATH=$(which poweroff)
|
|||||||
BASH_PATH=$(which bash)
|
BASH_PATH=$(which bash)
|
||||||
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||||
|
|
||||||
# Create sudoers content
|
# The rules themselves live in scripts/install/lib_sudoers.sh, shared with
|
||||||
cat > "$SUDOERS_TMP" << EOF
|
# scripts/install/configure_web_sudo.sh so the two cannot drift apart again.
|
||||||
# LED Matrix Web Interface passwordless sudo configuration
|
# If it is missing (a damaged checkout), keep whatever is already installed
|
||||||
# This allows the web interface user to run specific commands without a password
|
# rather than failing the whole install; the gate below skips the install.
|
||||||
|
SUDOERS_VALID=1
|
||||||
# Allow $ACTUAL_USER to run specific commands without a password for the LED Matrix web interface
|
SUDOERS_LIB="$PROJECT_ROOT_DIR/scripts/install/lib_sudoers.sh"
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH
|
if [ -f "$SUDOERS_LIB" ]; then
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH
|
# shellcheck source=scripts/install/lib_sudoers.sh
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
|
. "$SUDOERS_LIB"
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
|
web_sudoers_rules "$ACTUAL_USER" "$PROJECT_ROOT_DIR" "$SYSTEMCTL_PATH" "$BASH_PATH" \
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$SUDOERS_TMP"
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
else
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
SUDOERS_VALID=0
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
echo "⚠ $SUDOERS_LIB not found; cannot generate the sudoers rules." >&2
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
echo "⚠ Leaving $SUDOERS_FILE unchanged. The web interface cannot control" >&2
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
echo " the display service until this is fixed." >&2
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
|
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
|
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
|
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
|
||||||
# Install a requirements.txt as root via vetted helper, so packages are visible
|
|
||||||
# to root-run ledmatrix.service (not just the web interface's own user).
|
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
|
|
||||||
EOF
|
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
|
||||||
cat >> "$SUDOERS_TMP" << EOF
|
|
||||||
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
|
||||||
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
|
||||||
# shell -- the standard journalctl escalation. The web interface always passes
|
|
||||||
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
|
|
||||||
# sits in the middle of the command line. NOEXEC stops the command executing
|
|
||||||
# another program at all, which closes the hole without depending on wildcard
|
|
||||||
# matching subtleties.
|
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
|
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
|
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
|
|
||||||
EOF
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Never install rules we have not parsed. A malformed drop-in in
|
# Never install rules we have not parsed. A malformed drop-in in
|
||||||
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
|
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
|
||||||
# headless Pi leaves no way in at all. If the rules do not parse, say so and
|
# headless Pi leaves no way in at all. If the rules do not parse, say so and
|
||||||
# keep whatever is already installed.
|
# keep whatever is already installed.
|
||||||
SUDOERS_VALID=1
|
if [ "$SUDOERS_VALID" = "0" ]; then
|
||||||
if command -v visudo >/dev/null 2>&1; then
|
: # nothing was generated; already reported above
|
||||||
|
elif command -v visudo >/dev/null 2>&1; then
|
||||||
if ! visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then
|
if ! visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then
|
||||||
SUDOERS_VALID=0
|
SUDOERS_VALID=0
|
||||||
echo "⚠ The generated sudoers rules did not parse:" >&2
|
echo "⚠ The generated sudoers rules did not parse:" >&2
|
||||||
@@ -1690,28 +1655,8 @@ fi
|
|||||||
|
|
||||||
# Re-apply plugin directory permissions based on web service user
|
# Re-apply plugin directory permissions based on web service user
|
||||||
echo "Re-applying plugin directory permissions..."
|
echo "Re-applying plugin directory permissions..."
|
||||||
# Determine web service user (check installed service, install scripts, or template)
|
# Determine ownership based on web service user
|
||||||
WEB_SERVICE_USER="root"
|
detect_web_service_user
|
||||||
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
|
||||||
# Check actual installed service file (most accurate)
|
|
||||||
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
|
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
|
||||||
# Check install_web_service.sh (used by first_time_install.sh)
|
|
||||||
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="root"
|
|
||||||
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
|
||||||
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
|
|
||||||
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
|
|
||||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
|
||||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Set ownership based on web service user
|
# Set ownership based on web service user
|
||||||
if [ "$WEB_SERVICE_USER" = "$ACTUAL_USER" ] || [ "$WEB_SERVICE_USER" != "root" ]; then
|
if [ "$WEB_SERVICE_USER" = "$ACTUAL_USER" ] || [ "$WEB_SERVICE_USER" != "root" ]; then
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The rules are shared with first_time_install.sh (Step 10) so the two cannot
|
||||||
|
# drift apart; add or remove a grant in lib_sudoers.sh, not here.
|
||||||
|
SUDOERS_LIB="$PROJECT_DIR/lib_sudoers.sh"
|
||||||
|
if [ ! -f "$SUDOERS_LIB" ]; then
|
||||||
|
echo "Error: Sudoers rules library not found: $SUDOERS_LIB" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck source=scripts/install/lib_sudoers.sh
|
||||||
|
. "$SUDOERS_LIB"
|
||||||
|
|
||||||
echo "Command paths:"
|
echo "Command paths:"
|
||||||
echo " Python: $PYTHON_PATH"
|
echo " Python: $PYTHON_PATH"
|
||||||
echo " Systemctl: $SYSTEMCTL_PATH"
|
echo " Systemctl: $SYSTEMCTL_PATH"
|
||||||
@@ -72,56 +82,8 @@ echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
|
|||||||
# Create a temporary sudoers file
|
# Create a temporary sudoers file
|
||||||
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||||
|
|
||||||
{
|
web_sudoers_rules "$WEB_USER" "$PROJECT_ROOT" "$SYSTEMCTL_PATH" "$BASH_PATH" \
|
||||||
echo "# LED Matrix Web Interface passwordless sudo configuration"
|
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$TEMP_SUDOERS"
|
||||||
echo "# This allows the web interface user to run specific commands without a password"
|
|
||||||
echo ""
|
|
||||||
echo "# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface"
|
|
||||||
|
|
||||||
# Optional: reboot/poweroff (non-critical — skip if not found)
|
|
||||||
if [ -n "$REBOOT_PATH" ]; then
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$POWEROFF_PATH" ]; then
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Required: systemctl
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
|
||||||
|
|
||||||
# Optional: journalctl (non-critical — skip if not found)
|
|
||||||
#
|
|
||||||
# NOEXEC, matching first_time_install.sh. These rules end in a wildcard and
|
|
||||||
# journalctl starts a pager, so without it the caller can reach a shell:
|
|
||||||
# less runs "!command" as the user the pager belongs to, which here is
|
|
||||||
# root. NOEXEC stops the granted command executing anything of its own.
|
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "# Allow web user to remove plugin directories via vetted helper script"
|
|
||||||
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
|
||||||
echo ""
|
|
||||||
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
|
|
||||||
echo "# helper script, so packages are visible to root-run ledmatrix.service"
|
|
||||||
echo "# (not just the web interface's own user). The helper validates the target"
|
|
||||||
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
|
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
|
|
||||||
} > "$TEMP_SUDOERS"
|
|
||||||
|
|
||||||
# Never offer to install rules we have not parsed. A malformed drop-in in
|
# Never offer to install rules we have not parsed. A malformed drop-in in
|
||||||
# /etc/sudoers.d makes sudo refuse every command for every user.
|
# /etc/sudoers.d makes sudo refuse every command for every user.
|
||||||
|
|||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# The web interface's passwordless-sudo allow-list, /etc/sudoers.d/ledmatrix_web.
|
||||||
|
#
|
||||||
|
# Sourced by first_time_install.sh (Step 10) and
|
||||||
|
# scripts/install/configure_web_sudo.sh. Both used to carry their own copy of
|
||||||
|
# these rules, and the copies drifted: one granted safe_pip_install.sh and the
|
||||||
|
# other did not. Each caller still owns its own validate (visudo -c) / install /
|
||||||
|
# confirm flow; this file only prints the rules.
|
||||||
|
#
|
||||||
|
# Add or remove a grant here and nowhere else.
|
||||||
|
|
||||||
|
# web_sudoers_rules WEB_USER PROJECT_ROOT SYSTEMCTL_PATH BASH_PATH REBOOT_PATH POWEROFF_PATH JOURNALCTL_PATH
|
||||||
|
#
|
||||||
|
# Print the ledmatrix_web sudoers rules to stdout.
|
||||||
|
#
|
||||||
|
# SYSTEMCTL_PATH and BASH_PATH are required, and the caller must make sure they
|
||||||
|
# are not empty: `visudo -c` does not catch every such rule (with an empty
|
||||||
|
# BASH_PATH the helper rules still parse, granting the script itself).
|
||||||
|
# first_time_install.sh stops on a failed `which`; configure_web_sudo.sh checks
|
||||||
|
# them before calling this.
|
||||||
|
# REBOOT_PATH, POWEROFF_PATH and JOURNALCTL_PATH are optional: pass "" and
|
||||||
|
# their rules are left out.
|
||||||
|
web_sudoers_rules() {
|
||||||
|
local WEB_USER="${1:-}"
|
||||||
|
local PROJECT_ROOT="${2:-}"
|
||||||
|
local SYSTEMCTL_PATH="${3:-}"
|
||||||
|
local BASH_PATH="${4:-}"
|
||||||
|
local REBOOT_PATH="${5:-}"
|
||||||
|
local POWEROFF_PATH="${6:-}"
|
||||||
|
local JOURNALCTL_PATH="${7:-}"
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
# LED Matrix Web Interface passwordless sudo configuration
|
||||||
|
# This allows the web interface user to run specific commands without a password
|
||||||
|
|
||||||
|
# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface
|
||||||
|
EOF
|
||||||
|
if [ -n "$REBOOT_PATH" ]; then
|
||||||
|
printf '%s\n' "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
|
||||||
|
fi
|
||||||
|
if [ -n "$POWEROFF_PATH" ]; then
|
||||||
|
printf '%s\n' "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
|
||||||
|
fi
|
||||||
|
cat << EOF
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh *
|
||||||
|
# Install a requirements.txt as root via vetted helper, so packages are visible
|
||||||
|
# to root-run ledmatrix.service (not just the web interface's own user).
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *
|
||||||
|
EOF
|
||||||
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
|
cat << EOF
|
||||||
|
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
||||||
|
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
||||||
|
# shell -- the standard journalctl escalation. The web interface always passes
|
||||||
|
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
|
||||||
|
# sits in the middle of the command line. NOEXEC stops the command executing
|
||||||
|
# another program at all, which closes the hole without depending on wildcard
|
||||||
|
# matching subtleties.
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -320,5 +320,6 @@ def test_units_installers_and_updater_agree():
|
|||||||
assert (f'systemd/{unit}', f'/etc/systemd/system/{unit}') in StartupValidator._UNITS
|
assert (f'systemd/{unit}', f'/etc/systemd/system/{unit}') in StartupValidator._UNITS
|
||||||
|
|
||||||
# Triggering takes no privilege any more; no sudoers rule should linger.
|
# Triggering takes no privilege any more; no sudoers rule should linger.
|
||||||
for sudoers in ('scripts/install/configure_web_sudo.sh', 'first_time_install.sh'):
|
for sudoers in ('scripts/install/configure_web_sudo.sh', 'first_time_install.sh',
|
||||||
|
'scripts/install/lib_sudoers.sh'):
|
||||||
assert not re.search(r'NOPASSWD:.*update-verify', (ROOT / sudoers).read_text(encoding='utf-8')), sudoers
|
assert not re.search(r'NOPASSWD:.*update-verify', (ROOT / sudoers).read_text(encoding='utf-8')), sudoers
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||||||
INSTALLERS = (
|
INSTALLERS = (
|
||||||
ROOT / "first_time_install.sh",
|
ROOT / "first_time_install.sh",
|
||||||
ROOT / "scripts" / "install" / "configure_wifi_permissions.sh",
|
ROOT / "scripts" / "install" / "configure_wifi_permissions.sh",
|
||||||
|
# The ledmatrix_web rules, which first_time_install.sh and
|
||||||
|
# configure_web_sudo.sh both take from here.
|
||||||
|
ROOT / "scripts" / "install" / "lib_sudoers.sh",
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Commands this change grants, each fully literal in the source.
|
#: Commands this change grants, each fully literal in the source.
|
||||||
|
|||||||
@@ -62,33 +62,135 @@ def test_configure_web_sudo_validates_before_installing():
|
|||||||
assert validate < install, "the rules must be checked before they are installed"
|
assert validate < install, "the rules must be checked before they are installed"
|
||||||
|
|
||||||
|
|
||||||
def _render_first_time_sudoers(project_root, user):
|
def test_a_missing_rules_library_installs_nothing():
|
||||||
"""Run the installer's own sudoers heredoc with realistic values."""
|
"""If lib_sudoers.sh is missing, nothing is generated -- and an empty file
|
||||||
|
would pass `visudo -c` -- so that branch must set the flag the install is
|
||||||
|
gated on."""
|
||||||
body = _read(FIRST_TIME)
|
body = _read(FIRST_TIME)
|
||||||
start = body.index("# Create sudoers content")
|
missing = body.index('if [ -f "$SUDOERS_LIB" ]; then')
|
||||||
|
flagged = body.index("SUDOERS_VALID=0", missing)
|
||||||
|
validate = body.index('visudo -c -f "$SUDOERS_TMP"')
|
||||||
|
install = body.index('cp "$SUDOERS_TMP" "$SUDOERS_FILE"')
|
||||||
|
gate = body.rindex('if [ "$SUDOERS_VALID" = "0" ]; then', 0, install)
|
||||||
|
assert missing < flagged < validate < gate < install
|
||||||
|
|
||||||
|
|
||||||
|
def _step10_generation(body):
|
||||||
|
"""first_time_install.sh's own Step 10 code that writes $SUDOERS_TMP."""
|
||||||
|
start = body.index("# The rules themselves live in scripts/install/lib_sudoers.sh")
|
||||||
end = body.index("# Never install rules we have not parsed.")
|
end = body.index("# Never install rules we have not parsed.")
|
||||||
block = body[start:end]
|
return body[start:end]
|
||||||
out = os.path.join(project_root, "rendered")
|
|
||||||
|
|
||||||
|
def _run_step10_generation(project_root, user, out):
|
||||||
|
"""Run the installer's Step 10 generation with realistic values.
|
||||||
|
|
||||||
|
Returns the SUDOERS_VALID it leaves behind."""
|
||||||
script = "\n".join(
|
script = "\n".join(
|
||||||
[
|
[
|
||||||
"set -euo pipefail",
|
"set -Eeuo pipefail",
|
||||||
f"ACTUAL_USER={user}",
|
f"ACTUAL_USER={user}",
|
||||||
f"PROJECT_ROOT_DIR={project_root}",
|
f"PROJECT_ROOT_DIR='{project_root}'",
|
||||||
'SUDOERS_TMP="$(mktemp)"',
|
f"SUDOERS_TMP='{out}'",
|
||||||
"PYTHON_PATH=$(which python3)",
|
"SUDOERS_FILE=/etc/sudoers.d/ledmatrix_web",
|
||||||
"SYSTEMCTL_PATH=/usr/bin/systemctl",
|
"SYSTEMCTL_PATH=/usr/bin/systemctl",
|
||||||
"REBOOT_PATH=/usr/sbin/reboot",
|
"REBOOT_PATH=/usr/sbin/reboot",
|
||||||
"POWEROFF_PATH=/usr/sbin/poweroff",
|
"POWEROFF_PATH=/usr/sbin/poweroff",
|
||||||
"BASH_PATH=$(which bash)",
|
"BASH_PATH=$(which bash)",
|
||||||
"JOURNALCTL_PATH=/usr/bin/journalctl",
|
"JOURNALCTL_PATH=/usr/bin/journalctl",
|
||||||
block,
|
_step10_generation(_read(FIRST_TIME)),
|
||||||
f'cp "$SUDOERS_TMP" {out}',
|
'printf %s "$SUDOERS_VALID"',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
subprocess.run(["bash", "-c", script], check=True)
|
return subprocess.run(
|
||||||
|
["bash", "-c", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _render_first_time_sudoers(tmp, user):
|
||||||
|
"""The rules first_time_install.sh generates, via the shared library."""
|
||||||
|
out = os.path.join(tmp, "rendered")
|
||||||
|
assert _run_step10_generation(REPO_ROOT, user, out) == "1"
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _run_step10(tmp, project_root, visudo_ok, existing=None):
|
||||||
|
"""Run all of Step 10 against a sudoers file in `tmp`, never /etc.
|
||||||
|
|
||||||
|
systemctl, reboot, poweroff, journalctl and visudo are stubs, so the
|
||||||
|
outcome does not depend on the machine running the test."""
|
||||||
|
body = _read(FIRST_TIME)
|
||||||
|
step = body[body.index('CURRENT_STEP="Configure passwordless sudo access"'):
|
||||||
|
body.index('CURRENT_STEP="Configure WiFi management permissions"')]
|
||||||
|
target = os.path.join(tmp, "ledmatrix_web")
|
||||||
|
real = 'SUDOERS_FILE="/etc/sudoers.d/ledmatrix_web"'
|
||||||
|
assert step.count(real) == 1
|
||||||
|
step = step.replace(real, f"SUDOERS_FILE='{target}'")
|
||||||
|
stubs = os.path.join(tmp, "stubs")
|
||||||
|
os.mkdir(stubs)
|
||||||
|
for name, code in (("systemctl", 0), ("reboot", 0), ("poweroff", 0),
|
||||||
|
("journalctl", 0), ("visudo", 0 if visudo_ok else 1)):
|
||||||
|
path = os.path.join(stubs, name)
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(f"#!/bin/sh\nexit {code}\n")
|
||||||
|
os.chmod(path, 0o755)
|
||||||
|
if existing is not None:
|
||||||
|
with open(target, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(existing)
|
||||||
|
env = dict(os.environ, TMPDIR=tmp,
|
||||||
|
PATH=os.pathsep.join([stubs, os.path.dirname(sys.executable),
|
||||||
|
"/usr/bin", "/bin"]))
|
||||||
|
script = "\n".join(["set -Eeuo pipefail", "ACTUAL_USER=ledmatrix",
|
||||||
|
f"PROJECT_ROOT_DIR='{project_root}'", step])
|
||||||
|
result = subprocess.run(["bash", "-c", script], env=env,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
return target, stubs, result
|
||||||
|
|
||||||
|
|
||||||
|
_POSIX_STEP10 = pytest.mark.skipif(
|
||||||
|
sys.platform == "win32" or shutil.which("which") is None,
|
||||||
|
reason="needs a POSIX bash and which")
|
||||||
|
|
||||||
|
|
||||||
|
@_POSIX_STEP10
|
||||||
|
def test_step10_installs_the_generated_rules():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
target, stubs, _ = _run_step10(tmp, REPO_ROOT, visudo_ok=True)
|
||||||
|
assert oct(os.stat(target).st_mode & 0o777) == "0o440"
|
||||||
|
with open(target, encoding="utf-8") as handle:
|
||||||
|
installed = handle.read()
|
||||||
|
lib = os.path.join(REPO_ROOT, "scripts", "install", "lib_sudoers.sh")
|
||||||
|
expected = subprocess.run(
|
||||||
|
["bash", "-c", '. "$1"; web_sudoers_rules ledmatrix "$2" "$3/systemctl" '
|
||||||
|
'"$(command -v bash)" "$3/reboot" "$3/poweroff" "$3/journalctl"',
|
||||||
|
"_", lib, REPO_ROOT, stubs],
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
env=dict(os.environ, PATH=os.pathsep.join([stubs, "/usr/bin", "/bin"])),
|
||||||
|
).stdout
|
||||||
|
assert installed == expected
|
||||||
|
assert not [f for f in os.listdir(tmp) if f.startswith("ledmatrix_web_sudoers.")]
|
||||||
|
|
||||||
|
|
||||||
|
@_POSIX_STEP10
|
||||||
|
def test_step10_without_the_library_keeps_the_existing_file():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
target, _, result = _run_step10(tmp, tmp, visudo_ok=True, existing="keep\n")
|
||||||
|
with open(target, encoding="utf-8") as handle:
|
||||||
|
assert handle.read() == "keep\n"
|
||||||
|
assert "lib_sudoers.sh not found" in result.stderr
|
||||||
|
assert "Passwordless sudo access configured" not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@_POSIX_STEP10
|
||||||
|
def test_step10_keeps_the_existing_file_when_the_rules_do_not_parse():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
target, _, result = _run_step10(tmp, REPO_ROOT, visudo_ok=False, existing="keep\n")
|
||||||
|
with open(target, encoding="utf-8") as handle:
|
||||||
|
assert handle.read() == "keep\n"
|
||||||
|
assert "did not parse" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == "win32", reason="visudo is POSIX only")
|
@pytest.mark.skipif(sys.platform == "win32", reason="visudo is POSIX only")
|
||||||
@pytest.mark.skipif(VISUDO is None, reason="visudo not installed")
|
@pytest.mark.skipif(VISUDO is None, reason="visudo not installed")
|
||||||
def test_the_rules_the_installer_emits_actually_parse():
|
def test_the_rules_the_installer_emits_actually_parse():
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||||||
INSTALLERS = (
|
INSTALLERS = (
|
||||||
ROOT / "first_time_install.sh",
|
ROOT / "first_time_install.sh",
|
||||||
ROOT / "scripts" / "install" / "configure_wifi_permissions.sh",
|
ROOT / "scripts" / "install" / "configure_wifi_permissions.sh",
|
||||||
# Writes the same journalctl grants as first_time_install.sh. It was
|
# Used to write its own copy of the journalctl grants. It was missing
|
||||||
# missing here, and because of that this suite passed while three
|
# here, and because of that this suite passed while three untagged
|
||||||
# ungranted wildcard rules sat in it.
|
# wildcard rules sat in it. Both it and first_time_install.sh now take
|
||||||
|
# their rules from lib_sudoers.sh; they stay listed so a rule written
|
||||||
|
# directly into either one is still checked.
|
||||||
ROOT / "scripts" / "install" / "configure_web_sudo.sh",
|
ROOT / "scripts" / "install" / "configure_web_sudo.sh",
|
||||||
|
# The ledmatrix_web rules, shared by both installers.
|
||||||
|
ROOT / "scripts" / "install" / "lib_sudoers.sh",
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Commands that will start another program of their own accord -- a pager, an
|
#: Commands that will start another program of their own accord -- a pager, an
|
||||||
|
|||||||
@@ -1,53 +1,189 @@
|
|||||||
"""The two installers that write /etc/sudoers.d/ledmatrix_web must agree.
|
"""One generator writes /etc/sudoers.d/ledmatrix_web, and both installers use it.
|
||||||
|
|
||||||
first_time_install.sh (Step 10, a heredoc) and scripts/install/configure_web_sudo.sh
|
first_time_install.sh (Step 10) and scripts/install/configure_web_sudo.sh each
|
||||||
(a block of echo lines) each generate the web user's sudo allow-list. They
|
used to carry their own copy of the web user's sudo allow-list -- a heredoc in
|
||||||
drifted: configure_web_sudo.sh granted scripts/fix_perms/safe_pip_install.sh
|
one, a block of echo lines in the other -- and the copies drifted:
|
||||||
but first_time_install.sh did not, so on a device set up only by the first-time
|
configure_web_sudo.sh granted scripts/fix_perms/safe_pip_install.sh but
|
||||||
|
first_time_install.sh did not, so on a device set up only by the first-time
|
||||||
installer permission_utils.install_requirements_file could not use the root
|
installer permission_utils.install_requirements_file could not use the root
|
||||||
wrapper and fell back to a user-level install that root-run ledmatrix.service
|
wrapper and fell back to a user-level install that root-run ledmatrix.service
|
||||||
may not see (and the auto-update rollback reported its reinstall as failed).
|
may not see (and the auto-update rollback reported its reinstall as failed).
|
||||||
|
|
||||||
This compares the granted command sets after normalising the spellings that
|
The rules now live once, in web_sudoers_rules() in
|
||||||
differ between the files but expand identically at install time:
|
scripts/install/lib_sudoers.sh. What keeps them from drifting again:
|
||||||
$WEB_USER/$ACTUAL_USER, $PROJECT_ROOT/$PROJECT_ROOT_DIR, and the helper-path
|
|
||||||
variables configure_web_sudo.sh defines ($SAFE_RM_PATH, ...).
|
* neither installer writes a rule line of its own, and each writes the
|
||||||
|
generator's output to the very file it then validates and installs;
|
||||||
|
* each passes its variables to the generator in the right positions -- checked
|
||||||
|
by running the installer's own call line with distinct values;
|
||||||
|
* the generator's grants are pinned to an explicit list below, so dropping,
|
||||||
|
adding or re-pathing a grant is a deliberate edit to this file.
|
||||||
|
|
||||||
It also checks that every fix_perms helper granted via sudo is hardened to
|
It also checks that every fix_perms helper granted via sudo is hardened to
|
||||||
root:root in both scripts -- and, in first_time_install.sh, after Step 11's
|
root:root in both installers -- and, in first_time_install.sh, after Step 11's
|
||||||
project-wide chown to the user, which would otherwise undo it.
|
project-wide chown to the user, which would otherwise undo it.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
FIRST_TIME = ROOT / "first_time_install.sh"
|
FIRST_TIME = ROOT / "first_time_install.sh"
|
||||||
CONFIGURE = ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
CONFIGURE = ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||||
|
LIB = ROOT / "scripts" / "install" / "lib_sudoers.sh"
|
||||||
|
|
||||||
#: Grants that intentionally exist in only one installer, as normalised
|
#: Every grant web_sudoers_rules() writes, as (tags, command) with the
|
||||||
#: commands. There are none today; add one here with a reason rather than
|
#: generator's own variable names. Changing the allow-list means changing this.
|
||||||
#: loosening the comparison.
|
EXPECTED_GRANTS = frozenset({
|
||||||
ONLY_IN_FIRST_TIME = frozenset()
|
("NOPASSWD:", "$REBOOT_PATH"),
|
||||||
ONLY_IN_CONFIGURE = frozenset()
|
("NOPASSWD:", "$POWEROFF_PATH"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH start ledmatrix.service"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH stop ledmatrix.service"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH restart ledmatrix.service"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH enable ledmatrix.service"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH disable ledmatrix.service"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH status ledmatrix.service"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH is-active ledmatrix"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH is-active ledmatrix.service"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH start ledmatrix-web.service"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH stop ledmatrix-web.service"),
|
||||||
|
("NOPASSWD:", "$SYSTEMCTL_PATH restart ledmatrix-web.service"),
|
||||||
|
("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh *"),
|
||||||
|
("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *"),
|
||||||
|
("NOPASSWD:NOEXEC:", "$JOURNALCTL_PATH -u ledmatrix.service *"),
|
||||||
|
("NOPASSWD:NOEXEC:", "$JOURNALCTL_PATH -u ledmatrix *"),
|
||||||
|
("NOPASSWD:NOEXEC:", "$JOURNALCTL_PATH -t ledmatrix *"),
|
||||||
|
})
|
||||||
|
|
||||||
|
#: The call each installer makes: its own names for the generator's arguments,
|
||||||
|
#: in order, and the file it writes the rules to.
|
||||||
|
CALLERS = {
|
||||||
|
FIRST_TIME: (("$ACTUAL_USER", "$PROJECT_ROOT_DIR", "$SYSTEMCTL_PATH", "$BASH_PATH",
|
||||||
|
"$REBOOT_PATH", "$POWEROFF_PATH", "$JOURNALCTL_PATH"), "$SUDOERS_TMP"),
|
||||||
|
CONFIGURE: (("$WEB_USER", "$PROJECT_ROOT", "$SYSTEMCTL_PATH", "$BASH_PATH",
|
||||||
|
"$REBOOT_PATH", "$POWEROFF_PATH", "$JOURNALCTL_PATH"), "$TEMP_SUDOERS"),
|
||||||
|
}
|
||||||
|
|
||||||
|
RULE = re.compile(r'(\S+) ALL=\(ALL\) (NOPASSWD:(?:NOEXEC:)?)\s*(.*?)"?$')
|
||||||
|
|
||||||
|
|
||||||
def _text(path):
|
def _text(path):
|
||||||
return path.read_text(encoding="utf-8", errors="replace")
|
return path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||||
|
|
||||||
|
|
||||||
def _web_sudoers_section(path):
|
def _generator_grants():
|
||||||
"""The part of the script that writes the ledmatrix_web allow-list.
|
"""{(tags, command)} for every rule line in lib_sudoers.sh."""
|
||||||
|
grants = set()
|
||||||
|
for line in _text(LIB).splitlines():
|
||||||
|
m = RULE.search(line.strip())
|
||||||
|
if m and m.group(1).endswith("$WEB_USER"):
|
||||||
|
grants.add((m.group(2), " ".join(m.group(3).split())))
|
||||||
|
return grants
|
||||||
|
|
||||||
first_time_install.sh also writes other files later (WiFi permissions are
|
|
||||||
delegated to a separate script, but keep this robust against future
|
def _call(path):
|
||||||
additions), so restrict it to Step 10.
|
"""The installer's web_sudoers_rules statement, continuation lines joined."""
|
||||||
"""
|
|
||||||
text = _text(path)
|
text = _text(path)
|
||||||
if path == FIRST_TIME:
|
calls = re.findall(r"^[ \t]*web_sudoers_rules\b(?:[^\n]*\\\n)*[^\n]*$", text, re.M)
|
||||||
start = text.index('CURRENT_STEP="Configure passwordless sudo access"')
|
assert len(calls) == 1, f"{path.name}: expected one web_sudoers_rules call, found {calls}"
|
||||||
end = text.index('CURRENT_STEP="Configure WiFi management permissions"')
|
return calls[0]
|
||||||
return text[start:end]
|
|
||||||
return text
|
|
||||||
|
def test_generator_grants_exactly_the_expected_rules():
|
||||||
|
grants = _generator_grants()
|
||||||
|
assert grants == EXPECTED_GRANTS, (
|
||||||
|
f"lib_sudoers.sh grants changed:\n added: {sorted(grants - EXPECTED_GRANTS)}\n"
|
||||||
|
f" removed: {sorted(EXPECTED_GRANTS - grants)}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("installer", [FIRST_TIME, CONFIGURE], ids=lambda p: p.name)
|
||||||
|
def test_installer_writes_no_rules_of_its_own(installer):
|
||||||
|
"""A rule added to one installer only is how they drifted last time."""
|
||||||
|
own = [line for line in _text(installer).splitlines()
|
||||||
|
if "NOPASSWD" in line and not line.lstrip().startswith("#")]
|
||||||
|
assert not own, f"{installer.name} writes sudoers rules itself: {own}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("installer", [FIRST_TIME, CONFIGURE], ids=lambda p: p.name)
|
||||||
|
def test_installer_sources_the_generator_and_writes_what_it_validates(installer):
|
||||||
|
text = _text(installer)
|
||||||
|
assert "lib_sudoers.sh" in text, f"{installer.name} does not source lib_sudoers.sh"
|
||||||
|
args, target = CALLERS[installer]
|
||||||
|
call = _call(installer)
|
||||||
|
words = call.replace("\\\n", " ").split()
|
||||||
|
assert words[0] == "web_sudoers_rules"
|
||||||
|
assert tuple(w.strip('"') for w in words[1:8]) == args, (
|
||||||
|
f"{installer.name} passes the generator's arguments out of order: {call}")
|
||||||
|
assert words[8:] == [">", f'"{target}"'], call
|
||||||
|
# ...and that file is the one it runs visudo on.
|
||||||
|
assert f'visudo -c -f "{target}"' in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
|
||||||
|
reason="needs a POSIX bash")
|
||||||
|
@pytest.mark.parametrize("installer", [FIRST_TIME, CONFIGURE], ids=lambda p: p.name)
|
||||||
|
def test_installer_call_renders_the_expected_rules(installer, tmp_path):
|
||||||
|
"""Run the installer's own call line, with a distinct value per argument."""
|
||||||
|
args, target = CALLERS[installer]
|
||||||
|
values = {
|
||||||
|
args[0]: "webuser", args[1]: "/srv/led root", args[2]: "/x/systemctl",
|
||||||
|
args[3]: "/x/bash", args[4]: "/x/reboot", args[5]: "/x/poweroff",
|
||||||
|
args[6]: "/x/journalctl", target: str(tmp_path / "out"),
|
||||||
|
}
|
||||||
|
assigns = "\n".join(f"{name[1:]}='{value}'" for name, value in values.items())
|
||||||
|
script = f"set -euo pipefail\n. '{LIB}'\n{assigns}\n{_call(installer)}\n"
|
||||||
|
subprocess.run(["bash", "-c", script], check=True)
|
||||||
|
rendered = set()
|
||||||
|
for line in (tmp_path / "out").read_text(encoding="utf-8").splitlines():
|
||||||
|
m = RULE.match(line)
|
||||||
|
if m:
|
||||||
|
assert m.group(1) == "webuser", line
|
||||||
|
rendered.add((m.group(2), m.group(3)))
|
||||||
|
subst = {"$SYSTEMCTL_PATH": "/x/systemctl", "$BASH_PATH": "/x/bash",
|
||||||
|
"$REBOOT_PATH": "/x/reboot", "$POWEROFF_PATH": "/x/poweroff",
|
||||||
|
"$JOURNALCTL_PATH": "/x/journalctl", "$PROJECT_ROOT": "/srv/led root"}
|
||||||
|
expected = set()
|
||||||
|
for tags, command in EXPECTED_GRANTS:
|
||||||
|
for var, value in subst.items():
|
||||||
|
command = command.replace(var, value)
|
||||||
|
expected.add((tags, command))
|
||||||
|
assert rendered == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
|
||||||
|
reason="needs a POSIX bash")
|
||||||
|
def test_optional_tools_are_left_out_when_absent(tmp_path):
|
||||||
|
"""configure_web_sudo.sh passes "" for a missing reboot/poweroff/journalctl.
|
||||||
|
|
||||||
|
An empty path would otherwise leave `user ALL=(ALL) NOPASSWD: ` behind,
|
||||||
|
which visudo rejects, and the whole file would not be installed.
|
||||||
|
"""
|
||||||
|
out = subprocess.run(
|
||||||
|
["bash", "-c", f". '{LIB}'; web_sudoers_rules u /p /bin/systemctl /bin/bash '' '' ''"],
|
||||||
|
check=True, capture_output=True, text=True).stdout
|
||||||
|
rules = [line for line in out.splitlines() if RULE.match(line)]
|
||||||
|
assert len(rules) == len(EXPECTED_GRANTS) - 5
|
||||||
|
assert not [r for r in rules if r.rstrip().endswith("NOPASSWD:")]
|
||||||
|
assert "journalctl" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_pip_install_helper_is_granted():
|
||||||
|
wanted = ("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *")
|
||||||
|
assert wanted in _generator_grants()
|
||||||
|
|
||||||
|
|
||||||
|
def _granted_helpers():
|
||||||
|
helpers = set()
|
||||||
|
for _, command in _generator_grants():
|
||||||
|
m = re.search(r"scripts/fix_perms/([\w.-]+\.sh)", command)
|
||||||
|
if m:
|
||||||
|
helpers.add(m.group(1))
|
||||||
|
assert helpers, "no fix_perms helper grant found; the parser matched nothing"
|
||||||
|
return helpers
|
||||||
|
|
||||||
|
|
||||||
def _variables(text):
|
def _variables(text):
|
||||||
@@ -60,57 +196,9 @@ def _normalise(command, variables):
|
|||||||
for _ in range(3): # helper paths reference $PROJECT_ROOT
|
for _ in range(3): # helper paths reference $PROJECT_ROOT
|
||||||
command = re.sub(r"\$\{?([A-Z][A-Z0-9_]*)\}?",
|
command = re.sub(r"\$\{?([A-Z][A-Z0-9_]*)\}?",
|
||||||
lambda m: variables.get(m.group(1), m.group(0)), command)
|
lambda m: variables.get(m.group(1), m.group(0)), command)
|
||||||
command = command.replace("$PROJECT_ROOT_DIR", "$PROJECT_ROOT")
|
|
||||||
return " ".join(command.split())
|
return " ".join(command.split())
|
||||||
|
|
||||||
|
|
||||||
def _grants(path):
|
|
||||||
"""{(tags, command)} for every ledmatrix_web rule the script writes."""
|
|
||||||
section = _web_sudoers_section(path)
|
|
||||||
variables = _variables(_text(path))
|
|
||||||
grants = set()
|
|
||||||
for line in section.splitlines():
|
|
||||||
m = re.search(r'\$(?:WEB_USER|ACTUAL_USER) ALL=\(ALL\) (NOPASSWD:(?:NOEXEC:)?)\s*(.*)$',
|
|
||||||
line)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
command = m.group(2).rstrip().rstrip('"').rstrip()
|
|
||||||
grants.add((m.group(1), _normalise(command, variables)))
|
|
||||||
return grants
|
|
||||||
|
|
||||||
|
|
||||||
def test_both_installers_generate_rules():
|
|
||||||
# Guards against the parser silently matching nothing in either file.
|
|
||||||
assert len(_grants(FIRST_TIME)) >= 15
|
|
||||||
assert len(_grants(CONFIGURE)) >= 15
|
|
||||||
|
|
||||||
|
|
||||||
def test_installers_grant_the_same_commands():
|
|
||||||
first = _grants(FIRST_TIME)
|
|
||||||
configure = _grants(CONFIGURE)
|
|
||||||
only_first = {c for c in first - configure if c[1] not in ONLY_IN_FIRST_TIME}
|
|
||||||
only_configure = {c for c in configure - first if c[1] not in ONLY_IN_CONFIGURE}
|
|
||||||
assert not only_first and not only_configure, (
|
|
||||||
"ledmatrix_web sudoers drift between installers:\n"
|
|
||||||
f" only in first_time_install.sh: {sorted(only_first)}\n"
|
|
||||||
f" only in configure_web_sudo.sh: {sorted(only_configure)}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_pip_install_helper_is_granted():
|
|
||||||
wanted = ("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *")
|
|
||||||
assert wanted in _grants(FIRST_TIME)
|
|
||||||
assert wanted in _grants(CONFIGURE)
|
|
||||||
|
|
||||||
|
|
||||||
def _granted_helpers():
|
|
||||||
helpers = set()
|
|
||||||
for _, command in _grants(FIRST_TIME) | _grants(CONFIGURE):
|
|
||||||
m = re.search(r"scripts/fix_perms/([\w.-]+\.sh)", command)
|
|
||||||
if m:
|
|
||||||
helpers.add(m.group(1))
|
|
||||||
return helpers
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_granted_helper_is_hardened_in_configure_web_sudo():
|
def test_every_granted_helper_is_hardened_in_configure_web_sudo():
|
||||||
text = _text(CONFIGURE)
|
text = _text(CONFIGURE)
|
||||||
variables = _variables(text)
|
variables = _variables(text)
|
||||||
@@ -138,9 +226,8 @@ def test_no_grant_runs_a_file_the_web_user_can_edit():
|
|||||||
rule for it lets the web user rewrite the file and run it as root. The
|
rule for it lets the web user rewrite the file and run it as root. The
|
||||||
grants for display_controller.py, start_display.sh and stop_display.sh
|
grants for display_controller.py, start_display.sh and stop_display.sh
|
||||||
were exactly that, and nothing ever ran them through sudo."""
|
were exactly that, and nothing ever ran them through sudo."""
|
||||||
for installer in (FIRST_TIME, CONFIGURE):
|
for _, command in _generator_grants():
|
||||||
for _, command in _grants(installer):
|
for token in command.split():
|
||||||
for token in command.split():
|
if token.startswith("$PROJECT_ROOT/"):
|
||||||
if token.startswith("$PROJECT_ROOT/"):
|
assert token.startswith("$PROJECT_ROOT/scripts/fix_perms/"), (
|
||||||
assert token.startswith("$PROJECT_ROOT/scripts/fix_perms/"), (
|
f"lib_sudoers.sh grants root on a user-owned file: {command}")
|
||||||
f"{installer.name} grants root on a user-owned file: {command}")
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Guards that every privileged systemctl call the web interface makes is
|
"""Guards that every privileged systemctl call the web interface makes is
|
||||||
covered by a passwordless-sudo grant in configure_web_sudo.sh.
|
covered by a passwordless-sudo grant in scripts/install/lib_sudoers.sh, which
|
||||||
|
both first_time_install.sh and configure_web_sudo.sh write the rules from.
|
||||||
|
|
||||||
The web interface runs headless (no TTY), so any `sudo` call that is not
|
The web interface runs headless (no TTY), so any `sudo` call that is not
|
||||||
matched by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web falls back to a
|
matched by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web falls back to a
|
||||||
@@ -25,7 +26,7 @@ API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3"
|
|||||||
|
|
||||||
def _api_v3_source() -> str:
|
def _api_v3_source() -> str:
|
||||||
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
|
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
|
||||||
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "lib_sudoers.sh"
|
||||||
|
|
||||||
|
|
||||||
def _sudo_systemctl_calls(source: str) -> set[tuple[str, str]]:
|
def _sudo_systemctl_calls(source: str) -> set[tuple[str, str]]:
|
||||||
@@ -64,7 +65,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None:
|
|||||||
uncovered = {c for c in calls if c not in rules}
|
uncovered = {c for c in calls if c not in rules}
|
||||||
assert not uncovered, (
|
assert not uncovered, (
|
||||||
"These sudo systemctl calls have no matching NOPASSWD grant in "
|
"These sudo systemctl calls have no matching NOPASSWD grant in "
|
||||||
"configure_web_sudo.sh; they will fail headless with "
|
"lib_sudoers.sh; they will fail headless with "
|
||||||
"'sudo: a terminal is required to read the password': "
|
"'sudo: a terminal is required to read the password': "
|
||||||
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(uncovered))
|
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(uncovered))
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user