From 1fe7237799339f9562b9f28fed368530ed1e70d2 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:51:14 -0400 Subject: [PATCH] refactor(install): generate the web sudoers rules in one place (#622) * refactor(install): generate the web sudoers rules in one place /etc/sudoers.d/ledmatrix_web was written by two copies of the same allow-list: a heredoc in first_time_install.sh Step 10 and a block of echo lines in scripts/install/configure_web_sudo.sh. They drifted before (safe_pip_install.sh was granted by one only), and a test existed just to catch that. Both now call web_sudoers_rules() from the new scripts/install/lib_sudoers.sh and keep their own validate (visudo -c), install and confirm flows. - first_time_install.sh output is byte-for-byte unchanged, so a device re-running the installer gets "already up to date". If the library is missing, Step 10 keeps the installed file and carries on, the same way it handles rules that fail visudo (an empty file would pass visudo). - configure_web_sudo.sh now writes the installer's layout: same 18 rules, different comments and order. It still leaves out reboot, poweroff and journalctl when they are missing; the library does that for both. The drift test now pins the generator's grants, checks that neither installer writes rules of its own, and runs each installer's call line to check the argument order. Tests that read the rule text now read the library. Co-Authored-By: Claude Opus 5.5 * refactor(install): detect the web service user in one function first_time_install.sh pasted the same WEB_SERVICE_USER detection block three times (Step 3.1's fallback, the plugin-repos setup and Step 11). The copies were identical apart from comments; they now call detect_web_service_user(), whose body is that block unchanged. Behaviour is the same: the function sets the same global and always returns 0, as the inline if-chain did. Checked on Linux against all three original copies across 13 layouts (installed unit with and without User=, the repo as shipped, each grep branch, template placeholders). The comment notes that the install_web_service.sh / install_service.sh greps no longer match anything, so until Step 8 installs the unit the result is "root". That behaviour is left as it was. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/PLUGIN_DEPENDENCY_GUIDE.md | 3 +- first_time_install.sh | 171 ++++-------- scripts/install/configure_web_sudo.sh | 62 +---- scripts/install/lib_sudoers.sh | 76 ++++++ test/test_auto_update_verify.py | 3 +- test/test_sudo_allowlist_covers_calls.py | 3 + test/test_sudoers_is_validated.py | 126 ++++++++- test/test_sudoers_noexec_on_pagers.py | 10 +- test/test_web_sudoers_installers_agree.py | 249 ++++++++++++------ .../test_systemctl_sudoers_alignment.py | 7 +- 10 files changed, 446 insertions(+), 264 deletions(-) create mode 100755 scripts/install/lib_sudoers.sh diff --git a/docs/PLUGIN_DEPENDENCY_GUIDE.md b/docs/PLUGIN_DEPENDENCY_GUIDE.md index 5396b0c6..b5b321e0 100644 --- a/docs/PLUGIN_DEPENDENCY_GUIDE.md +++ b/docs/PLUGIN_DEPENDENCY_GUIDE.md @@ -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`) - 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`) -- 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` diff --git a/first_time_install.sh b/first_time_install.sh index 98fab2fd..8b8ca6dc 100755 --- a/first_time_install.sh +++ b/first_time_install.sh @@ -502,6 +502,41 @@ print_rgbmatrix_build_failure() { 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 "This script will perform the following steps:" echo "1. Check prerequisites (network, disk, memory) and install system dependencies" @@ -699,32 +734,7 @@ else fi # Determine ownership based on web service user - # Check if web service file exists and what user it runs as - 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 + detect_web_service_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). @@ -758,32 +768,7 @@ if [ ! -d "$PLUGIN_REPOS_DIR" ]; then fi # Determine ownership based on web service user -# Check if web service file exists and what user it runs as -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 +detect_web_service_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). @@ -1516,51 +1501,31 @@ POWEROFF_PATH=$(which poweroff) BASH_PATH=$(which bash) JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true) -# Create sudoers content -cat > "$SUDOERS_TMP" << EOF -# LED Matrix Web Interface passwordless sudo configuration -# This allows the web interface user to run specific commands without a password - -# Allow $ACTUAL_USER to run specific commands without a password for the LED Matrix web interface -$ACTUAL_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH -$ACTUAL_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH -$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service -$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service -$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service -$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service -$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service -$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service -$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix -$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service -$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 +# The rules themselves live in scripts/install/lib_sudoers.sh, shared with +# scripts/install/configure_web_sudo.sh so the two cannot drift apart again. +# If it is missing (a damaged checkout), keep whatever is already installed +# rather than failing the whole install; the gate below skips the install. +SUDOERS_VALID=1 +SUDOERS_LIB="$PROJECT_ROOT_DIR/scripts/install/lib_sudoers.sh" +if [ -f "$SUDOERS_LIB" ]; then + # shellcheck source=scripts/install/lib_sudoers.sh + . "$SUDOERS_LIB" + web_sudoers_rules "$ACTUAL_USER" "$PROJECT_ROOT_DIR" "$SYSTEMCTL_PATH" "$BASH_PATH" \ + "$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$SUDOERS_TMP" +else + SUDOERS_VALID=0 + echo "⚠ $SUDOERS_LIB not found; cannot generate the sudoers rules." >&2 + echo "⚠ Leaving $SUDOERS_FILE unchanged. The web interface cannot control" >&2 + echo " the display service until this is fixed." >&2 fi # 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 # headless Pi leaves no way in at all. If the rules do not parse, say so and # keep whatever is already installed. -SUDOERS_VALID=1 -if command -v visudo >/dev/null 2>&1; then +if [ "$SUDOERS_VALID" = "0" ]; 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 SUDOERS_VALID=0 echo "⚠ The generated sudoers rules did not parse:" >&2 @@ -1690,28 +1655,8 @@ fi # Re-apply plugin directory permissions based on web service user echo "Re-applying plugin directory permissions..." -# Determine web service user (check installed service, install scripts, or template) -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 - 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 +# Determine ownership based on web service user +detect_web_service_user # Set ownership based on web service user if [ "$WEB_SERVICE_USER" = "$ACTUAL_USER" ] || [ "$WEB_SERVICE_USER" != "root" ]; then diff --git a/scripts/install/configure_web_sudo.sh b/scripts/install/configure_web_sudo.sh index 762327e9..91ba1d3b 100755 --- a/scripts/install/configure_web_sudo.sh +++ b/scripts/install/configure_web_sudo.sh @@ -59,6 +59,16 @@ if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then exit 1 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 " Python: $PYTHON_PATH" echo " Systemctl: $SYSTEMCTL_PATH" @@ -72,56 +82,8 @@ echo " Safe pip install: $SAFE_PIP_INSTALL_PATH" # Create a temporary sudoers file TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$" -{ - echo "# LED Matrix Web Interface passwordless sudo configuration" - 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" +web_sudoers_rules "$WEB_USER" "$PROJECT_ROOT" "$SYSTEMCTL_PATH" "$BASH_PATH" \ + "$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$TEMP_SUDOERS" # 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. diff --git a/scripts/install/lib_sudoers.sh b/scripts/install/lib_sudoers.sh new file mode 100755 index 00000000..40fdd51c --- /dev/null +++ b/scripts/install/lib_sudoers.sh @@ -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 +} diff --git a/test/test_auto_update_verify.py b/test/test_auto_update_verify.py index c346091e..e491d42b 100644 --- a/test/test_auto_update_verify.py +++ b/test/test_auto_update_verify.py @@ -320,5 +320,6 @@ def test_units_installers_and_updater_agree(): assert (f'systemd/{unit}', f'/etc/systemd/system/{unit}') in StartupValidator._UNITS # 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 diff --git a/test/test_sudo_allowlist_covers_calls.py b/test/test_sudo_allowlist_covers_calls.py index bec2486b..8f3b88f7 100644 --- a/test/test_sudo_allowlist_covers_calls.py +++ b/test/test_sudo_allowlist_covers_calls.py @@ -37,6 +37,9 @@ ROOT = Path(__file__).resolve().parent.parent INSTALLERS = ( ROOT / "first_time_install.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. diff --git a/test/test_sudoers_is_validated.py b/test/test_sudoers_is_validated.py index a29c5779..a65bbd0e 100644 --- a/test/test_sudoers_is_validated.py +++ b/test/test_sudoers_is_validated.py @@ -62,33 +62,135 @@ def test_configure_web_sudo_validates_before_installing(): assert validate < install, "the rules must be checked before they are installed" -def _render_first_time_sudoers(project_root, user): - """Run the installer's own sudoers heredoc with realistic values.""" +def test_a_missing_rules_library_installs_nothing(): + """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) - 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.") - block = body[start:end] - out = os.path.join(project_root, "rendered") + return body[start:end] + + +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( [ - "set -euo pipefail", + "set -Eeuo pipefail", f"ACTUAL_USER={user}", - f"PROJECT_ROOT_DIR={project_root}", - 'SUDOERS_TMP="$(mktemp)"', - "PYTHON_PATH=$(which python3)", + f"PROJECT_ROOT_DIR='{project_root}'", + f"SUDOERS_TMP='{out}'", + "SUDOERS_FILE=/etc/sudoers.d/ledmatrix_web", "SYSTEMCTL_PATH=/usr/bin/systemctl", "REBOOT_PATH=/usr/sbin/reboot", "POWEROFF_PATH=/usr/sbin/poweroff", "BASH_PATH=$(which bash)", "JOURNALCTL_PATH=/usr/bin/journalctl", - block, - f'cp "$SUDOERS_TMP" {out}', + _step10_generation(_read(FIRST_TIME)), + '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 +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(VISUDO is None, reason="visudo not installed") def test_the_rules_the_installer_emits_actually_parse(): diff --git a/test/test_sudoers_noexec_on_pagers.py b/test/test_sudoers_noexec_on_pagers.py index ca657785..1f3983cf 100644 --- a/test/test_sudoers_noexec_on_pagers.py +++ b/test/test_sudoers_noexec_on_pagers.py @@ -30,10 +30,14 @@ ROOT = Path(__file__).resolve().parent.parent INSTALLERS = ( ROOT / "first_time_install.sh", ROOT / "scripts" / "install" / "configure_wifi_permissions.sh", - # Writes the same journalctl grants as first_time_install.sh. It was - # missing here, and because of that this suite passed while three - # ungranted wildcard rules sat in it. + # Used to write its own copy of the journalctl grants. It was missing + # here, and because of that this suite passed while three untagged + # 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", + # 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 diff --git a/test/test_web_sudoers_installers_agree.py b/test/test_web_sudoers_installers_agree.py index 7a6d10d7..248a5520 100644 --- a/test/test_web_sudoers_installers_agree.py +++ b/test/test_web_sudoers_installers_agree.py @@ -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 -(a block of echo lines) each generate the web user's sudo allow-list. They -drifted: 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 +first_time_install.sh (Step 10) and scripts/install/configure_web_sudo.sh each +used to carry their own copy of the web user's sudo allow-list -- a heredoc in +one, a block of echo lines in the other -- and the copies drifted: +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 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). -This compares the granted command sets after normalising the spellings that -differ between the files but expand identically at install time: -$WEB_USER/$ACTUAL_USER, $PROJECT_ROOT/$PROJECT_ROOT_DIR, and the helper-path -variables configure_web_sudo.sh defines ($SAFE_RM_PATH, ...). +The rules now live once, in web_sudoers_rules() in +scripts/install/lib_sudoers.sh. What keeps them from drifting again: + +* 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 -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. """ import re +import shutil +import subprocess +import sys from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parent.parent FIRST_TIME = ROOT / "first_time_install.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 -#: commands. There are none today; add one here with a reason rather than -#: loosening the comparison. -ONLY_IN_FIRST_TIME = frozenset() -ONLY_IN_CONFIGURE = frozenset() +#: Every grant web_sudoers_rules() writes, as (tags, command) with the +#: generator's own variable names. Changing the allow-list means changing this. +EXPECTED_GRANTS = frozenset({ + ("NOPASSWD:", "$REBOOT_PATH"), + ("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): - 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): - """The part of the script that writes the ledmatrix_web allow-list. +def _generator_grants(): + """{(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 - additions), so restrict it to Step 10. - """ + +def _call(path): + """The installer's web_sudoers_rules statement, continuation lines joined.""" text = _text(path) - if path == FIRST_TIME: - start = text.index('CURRENT_STEP="Configure passwordless sudo access"') - end = text.index('CURRENT_STEP="Configure WiFi management permissions"') - return text[start:end] - return text + calls = re.findall(r"^[ \t]*web_sudoers_rules\b(?:[^\n]*\\\n)*[^\n]*$", text, re.M) + assert len(calls) == 1, f"{path.name}: expected one web_sudoers_rules call, found {calls}" + return calls[0] + + +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): @@ -60,57 +196,9 @@ def _normalise(command, variables): for _ in range(3): # helper paths reference $PROJECT_ROOT command = re.sub(r"\$\{?([A-Z][A-Z0-9_]*)\}?", lambda m: variables.get(m.group(1), m.group(0)), command) - command = command.replace("$PROJECT_ROOT_DIR", "$PROJECT_ROOT") 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(): text = _text(CONFIGURE) 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 grants for display_controller.py, start_display.sh and stop_display.sh were exactly that, and nothing ever ran them through sudo.""" - for installer in (FIRST_TIME, CONFIGURE): - for _, command in _grants(installer): - for token in command.split(): - if token.startswith("$PROJECT_ROOT/"): - assert token.startswith("$PROJECT_ROOT/scripts/fix_perms/"), ( - f"{installer.name} grants root on a user-owned file: {command}") + for _, command in _generator_grants(): + for token in command.split(): + if token.startswith("$PROJECT_ROOT/"): + assert token.startswith("$PROJECT_ROOT/scripts/fix_perms/"), ( + f"lib_sudoers.sh grants root on a user-owned file: {command}") diff --git a/test/web_interface/test_systemctl_sudoers_alignment.py b/test/web_interface/test_systemctl_sudoers_alignment.py index 0013eccf..113b788d 100644 --- a/test/web_interface/test_systemctl_sudoers_alignment.py +++ b/test/web_interface/test_systemctl_sudoers_alignment.py @@ -1,5 +1,6 @@ """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 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: 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]]: @@ -64,7 +65,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None: uncovered = {c for c in calls if c not in rules} assert not uncovered, ( "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': " + ", ".join(f"systemctl {v} {u}" for v, u in sorted(uncovered)) )