From 8de706323a46b7ed5286a1af8d7f4e32c1feae61 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sat, 4 Jul 2026 09:46:47 -0400 Subject: [PATCH] fix: install plugin/base requirements as root so ledmatrix.service can see them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ledmatrix-web.service runs as a non-root user, so "Reinstall plugin requirements" installed packages into that user's ~/.local site-packages. ledmatrix.service (the actual display, which loads and runs plugin code) runs as root and can't see another user's user-site packages, so plugins with dependencies not already present system-wide would silently fail at runtime with ModuleNotFoundError even after a "successful" reinstall. Reproduced and fixed live against a real device (weather plugin's astral dependency, used for moon-phase data): confirmed the exact failure ("No module named 'astral'" on every almanac cycle) and confirmed it's gone after this fix. Adds scripts/fix_perms/safe_pip_install.sh, a root-owned wrapper (mirroring the existing safe_plugin_rm.sh pattern) that validates the target is requirements.txt at the project root or under plugin-repos/ or plugins/ before running pip install as root. configure_web_sudo.sh provisions a narrowly-scoped sudoers rule for it. api_v3.py's install_base_requirements and install_plugin_requirements actions now use it via `sudo -n`, falling back to today's current-user-only install (with an explanatory note) if the wrapper isn't set up yet, so existing installs don't regress. Also uses --ignore-installed in the wrapper: root's site-packages often has apt/dpkg-managed copies of common libraries (requests, etc.) with no pip RECORD file, which pip refuses to upgrade in place and aborts the *entire* requirements.txt install over — discovered this while testing the fix live, since a plugin's other already-satisfied-for-the-web-user dependencies had never actually been attempted as root before. Also fixes a pre-existing bug in configure_web_sudo.sh where the display_controller.py/start_display.sh/stop_display.sh sudoers entries used PROJECT_DIR (scripts/install/, where this script lives) instead of PROJECT_ROOT (where those files actually live) — visible as the script's own "File access test" self-check failing. Verified fixed live. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- scripts/fix_perms/safe_pip_install.sh | 74 +++++++++++++++++++++++++++ scripts/install/configure_web_sudo.sh | 33 ++++++++++-- web_interface/blueprints/api_v3.py | 46 ++++++++++++++--- 3 files changed, 140 insertions(+), 13 deletions(-) create mode 100755 scripts/fix_perms/safe_pip_install.sh diff --git a/scripts/fix_perms/safe_pip_install.sh b/scripts/fix_perms/safe_pip_install.sh new file mode 100755 index 00000000..ae7d266c --- /dev/null +++ b/scripts/fix_perms/safe_pip_install.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# safe_pip_install.sh — Install a requirements.txt as root after validating +# that the resolved path is the project's own requirements.txt or a plugin's +# requirements.txt under plugin-repos/ or plugins/. +# +# This script is intended to be called via sudo from the web interface, so +# that packages a plugin declares end up visible to ledmatrix.service (which +# runs as root) rather than only to whichever non-root user runs the web +# interface. Plugin code already runs as root once loaded, so installing its +# declared dependencies as root is not a new trust boundary. +# +# Usage: safe_pip_install.sh + +set -euo pipefail + +if [ $# -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +TARGET="$1" + +# Determine the project root (parent of scripts/fix_perms/) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# Allowed locations (resolved, no trailing slash): +# - the project's own requirements.txt +# - any requirements.txt under plugin-repos/ or plugins/ +ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")" +ALLOWED_BASES=( + "$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")" + "$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")" +) + +# Resolve the target path (follow symlinks); works even if it doesn't exist. +RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")" + +# Must be named requirements.txt — never install from an arbitrary file. +if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then + echo "DENIED: $RESOLVED_TARGET is not a requirements.txt file" >&2 + exit 2 +fi + +ALLOWED=false +if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then + ALLOWED=true +else + for BASE in "${ALLOWED_BASES[@]}"; do + if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then + ALLOWED=true + break + fi + done +fi + +if [ "$ALLOWED" = false ]; then + echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2 + echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2 + exit 2 +fi + +if [ ! -f "$RESOLVED_TARGET" ]; then + echo "ERROR: $RESOLVED_TARGET does not exist" >&2 + exit 3 +fi + +PYTHON_PATH="$(command -v python3)" +# --ignore-installed: root's site-packages often has apt/dpkg-managed copies +# of common libraries (requests, urllib3, ...) with no pip RECORD file, which +# pip refuses to uninstall in place ("Cannot uninstall: no RECORD file was +# found"). This tells pip to install the newer version alongside rather than +# aborting the whole requirements.txt install over one such conflict. +exec "$PYTHON_PATH" -m pip install --break-system-packages --ignore-installed -r "$RESOLVED_TARGET" diff --git a/scripts/install/configure_web_sudo.sh b/scripts/install/configure_web_sudo.sh index 0a9df2da..feccc3e7 100644 --- a/scripts/install/configure_web_sudo.sh +++ b/scripts/install/configure_web_sudo.sh @@ -33,6 +33,7 @@ POWEROFF_PATH=$(command -v poweroff) || true BASH_PATH=$(command -v bash) || true JOURNALCTL_PATH=$(command -v journalctl) || true SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh" +SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh" # Validate required commands (systemctl, bash, python3 are essential) for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do @@ -48,11 +49,15 @@ if [ ${#MISSING_CMDS[@]} -gt 0 ]; then exit 1 fi -# Validate helper script exists +# Validate helper scripts exist if [ ! -f "$SAFE_RM_PATH" ]; then echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2 exit 1 fi +if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then + echo "Error: Safe pip install helper not found: $SAFE_PIP_INSTALL_PATH" >&2 + exit 1 +fi echo "Command paths:" echo " Python: $PYTHON_PATH" @@ -62,6 +67,7 @@ echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}" echo " Bash: $BASH_PATH" echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}" echo " Safe plugin rm: $SAFE_RM_PATH" +echo " Safe pip install: $SAFE_PIP_INSTALL_PATH" # Create a temporary sudoers file TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$" @@ -101,13 +107,22 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$" fi # Required: python3, bash - echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py" - echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh" - echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh" + # NOTE: display_controller.py/start_display.sh/stop_display.sh live at the + # project root, not under scripts/install/ (where this script lives) — + # must use PROJECT_ROOT here, not PROJECT_DIR. + echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT/display_controller.py" + echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/start_display.sh" + echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/stop_display.sh" 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" echo "" @@ -126,6 +141,7 @@ echo "- Run display_controller.py directly" echo "- Execute start_display.sh and stop_display.sh" echo "- Reboot and shutdown the system" echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)" +echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)" echo "" # Ask for confirmation @@ -147,6 +163,13 @@ fi if ! sudo chmod 755 "$SAFE_RM_PATH"; then echo "Warning: Could not set permissions on $SAFE_RM_PATH" fi +echo "Hardening safe_pip_install.sh ownership..." +if ! sudo chown root:root "$SAFE_PIP_INSTALL_PATH"; then + echo "Warning: Could not set ownership on $SAFE_PIP_INSTALL_PATH" +fi +if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then + echo "Warning: Could not set permissions on $SAFE_PIP_INSTALL_PATH" +fi if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then echo "Configuration applied successfully!" @@ -160,7 +183,7 @@ if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then echo "✗ systemctl status ledmatrix.service - Failed" fi - if sudo -n test -f "$PROJECT_DIR/start_display.sh"; then + if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then echo "✓ File access test - OK" else echo "✗ File access test - Failed" diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index bfa86ddb..1c356cdb 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -43,6 +43,42 @@ def _truncate_output(stdout: str, stderr: str) -> str: return combined +def _pip_install_requirements(req_file: Path, timeout: int) -> subprocess.CompletedProcess: + """Install a requirements.txt file, preferring the vetted sudo wrapper so + the packages are visible to root-run ledmatrix.service — not just to + whichever non-root user runs this web process. Falls back to installing + for the current process only if the wrapper isn't set up yet (i.e. the + admin hasn't run scripts/install/configure_web_sudo.sh since upgrading), + so the button still does *something* useful rather than hard-failing. + """ + wrapper = PROJECT_ROOT / 'scripts' / 'fix_perms' / 'safe_pip_install.sh' + if wrapper.exists(): + result = subprocess.run( + ['sudo', '-n', str(wrapper), str(req_file)], + capture_output=True, text=True, timeout=timeout, cwd=str(PROJECT_ROOT) + ) + if result.returncode == 0: + return result + note = ( + f"[Root install unavailable ({result.stderr.strip() or 'sudo denied'}); " + "installed for the web service's user only. Packages may not be " + "visible to ledmatrix.service if it runs as a different user — " + "run scripts/install/configure_web_sudo.sh to fix this.]\n" + ) + else: + note = ( + "[safe_pip_install.sh not found; installed for the web service's " + "user only. Run scripts/install/configure_web_sudo.sh to enable " + "root installs visible to ledmatrix.service.]\n" + ) + result = subprocess.run( + [sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)], + capture_output=True, text=True, timeout=timeout, cwd=str(PROJECT_ROOT) + ) + result.stdout = note + (result.stdout or '') + return result + + def _scrub_git_remote_url(url: str) -> str: """Strip embedded username/password from an HTTPS remote URL before returning it to the UI.""" try: @@ -1671,10 +1707,7 @@ def execute_system_action(): req_file = PROJECT_ROOT / 'requirements.txt' if not req_file.exists(): return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'}) - result = subprocess.run( - [sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)], - capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT) - ) + result = _pip_install_requirements(req_file, timeout=120) return jsonify({ 'status': 'success' if result.returncode == 0 else 'error', 'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed', @@ -1695,10 +1728,7 @@ def execute_system_action(): req = p / 'requirements.txt' if p.is_dir() and req.exists(): try: - r = subprocess.run( - [sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req)], - capture_output=True, text=True, timeout=60 - ) + r = _pip_install_requirements(req, timeout=60) results.append({ 'plugin': p.name, 'ok': r.returncode == 0,