mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
fix: install plugin/base requirements as root so ledmatrix.service can see them
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c6ce332d49
commit
8de706323a
Executable
+74
@@ -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 <requirements_txt_path>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <requirements_txt_path>" >&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"
|
||||||
@@ -33,6 +33,7 @@ POWEROFF_PATH=$(command -v poweroff) || true
|
|||||||
BASH_PATH=$(command -v bash) || true
|
BASH_PATH=$(command -v bash) || true
|
||||||
JOURNALCTL_PATH=$(command -v journalctl) || true
|
JOURNALCTL_PATH=$(command -v journalctl) || true
|
||||||
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
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)
|
# Validate required commands (systemctl, bash, python3 are essential)
|
||||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
||||||
@@ -48,11 +49,15 @@ if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate helper script exists
|
# Validate helper scripts exist
|
||||||
if [ ! -f "$SAFE_RM_PATH" ]; then
|
if [ ! -f "$SAFE_RM_PATH" ]; then
|
||||||
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 "Command paths:"
|
||||||
echo " Python: $PYTHON_PATH"
|
echo " Python: $PYTHON_PATH"
|
||||||
@@ -62,6 +67,7 @@ echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
|
|||||||
echo " Bash: $BASH_PATH"
|
echo " Bash: $BASH_PATH"
|
||||||
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
||||||
echo " Safe plugin rm: $SAFE_RM_PATH"
|
echo " Safe plugin rm: $SAFE_RM_PATH"
|
||||||
|
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_$$"
|
||||||
@@ -101,13 +107,22 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Required: python3, bash
|
# Required: python3, bash
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py"
|
# NOTE: display_controller.py/start_display.sh/stop_display.sh live at the
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh"
|
# project root, not under scripts/install/ (where this script lives) —
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh"
|
# 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 ""
|
||||||
echo "# Allow web user to remove plugin directories via vetted helper script"
|
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 "# 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 "$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"
|
} > "$TEMP_SUDOERS"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -126,6 +141,7 @@ echo "- Run display_controller.py directly"
|
|||||||
echo "- Execute start_display.sh and stop_display.sh"
|
echo "- Execute start_display.sh and stop_display.sh"
|
||||||
echo "- Reboot and shutdown the system"
|
echo "- Reboot and shutdown the system"
|
||||||
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# Ask for confirmation
|
# Ask for confirmation
|
||||||
@@ -147,6 +163,13 @@ fi
|
|||||||
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
||||||
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
||||||
fi
|
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
|
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
||||||
echo "Configuration applied successfully!"
|
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"
|
echo "✗ systemctl status ledmatrix.service - Failed"
|
||||||
fi
|
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"
|
echo "✓ File access test - OK"
|
||||||
else
|
else
|
||||||
echo "✗ File access test - Failed"
|
echo "✗ File access test - Failed"
|
||||||
|
|||||||
@@ -43,6 +43,42 @@ def _truncate_output(stdout: str, stderr: str) -> str:
|
|||||||
return combined
|
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:
|
def _scrub_git_remote_url(url: str) -> str:
|
||||||
"""Strip embedded username/password from an HTTPS remote URL before returning it to the UI."""
|
"""Strip embedded username/password from an HTTPS remote URL before returning it to the UI."""
|
||||||
try:
|
try:
|
||||||
@@ -1671,10 +1707,7 @@ def execute_system_action():
|
|||||||
req_file = PROJECT_ROOT / 'requirements.txt'
|
req_file = PROJECT_ROOT / 'requirements.txt'
|
||||||
if not req_file.exists():
|
if not req_file.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
||||||
result = subprocess.run(
|
result = _pip_install_requirements(req_file, timeout=120)
|
||||||
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req_file)],
|
|
||||||
capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT)
|
|
||||||
)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success' if result.returncode == 0 else 'error',
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
|
'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'
|
req = p / 'requirements.txt'
|
||||||
if p.is_dir() and req.exists():
|
if p.is_dir() and req.exists():
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = _pip_install_requirements(req, timeout=60)
|
||||||
[sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-r', str(req)],
|
|
||||||
capture_output=True, text=True, timeout=60
|
|
||||||
)
|
|
||||||
results.append({
|
results.append({
|
||||||
'plugin': p.name,
|
'plugin': p.name,
|
||||||
'ok': r.returncode == 0,
|
'ok': r.returncode == 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user