mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes - font_manager: a .zip font URL is served as its extracted font after a restart (the cached-file check returned the archive first); downloads use requests with a 30s timeout into a temp file + os.replace. - api_helper / sync_manager: rate-limit and heartbeat/leader timeouts use time.monotonic(); last_request_time and the status file's ts stay wall-clock. set_on_new_cycle docstring no longer claims core uses it. - logo_helper: the placeholder uses the same scaled box as a real logo. - permission_utils: one _sudo_bash_candidates() helper (with the sudoers exact-argv rationale) shared by sudo_remove_directory, which now retries the next bash path on a sudo refusal, and install_requirements_file. - dynamic_team_resolver: failed/empty fetch backs off 5 min; duplicate INFO log and contradictory docstring example fixed. - element_style: scale default looked up through element aliases. - background_data_service: cache-hit callback runs outside the lock. - config_arrays: union-aware type check (["array","null"]); stale dotToNested() reference removed. - auto_update_setup: non-dict auto_update reads as off; temp result file unlinked when the write fails. - exceptions: constructors copy the caller's context dict. - logging_config: StructuredFormatter json.dumps(default=str). - error_aggregator: removed unused export_path/export_to_file/_auto_export. - Docstrings: validate_file_upload max_size_mb, raise_on_errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sync): retry the status-file rename like the other atomic writers On Windows os.replace can fail with "Access is denied" while a scanner briefly holds the target open; config_manager_atomic._replace already retries that (and re-raises at once on other platforms). The sync status writer called os.replace directly, which made test_concurrent_writers_each_use_their_own_temp_file flaky on Windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
517 lines
20 KiB
Python
517 lines
20 KiB
Python
"""
|
|
Permission Utilities
|
|
|
|
Centralized utility functions for managing file and directory permissions
|
|
across the LEDMatrix codebase. Ensures consistent permission handling for
|
|
files that need to be accessible by both root service and web user.
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
import re
|
|
import shutil as _shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
|
|
# own error output can be logged/displayed without echoing back a private
|
|
# index URL's embedded basic-auth secret verbatim (e.g. from a
|
|
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
|
|
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
|
|
|
|
|
|
def _redact_url_credentials(text: Optional[str]) -> str:
|
|
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
|
|
|
|
Safe to call on any subprocess output destined for logs: it only ever
|
|
shortens/replaces the credential substring, never changes the presence
|
|
or absence of the specific fixed phrases callers check for
|
|
(e.g. "a password is required"), so it can't affect control flow.
|
|
"""
|
|
if not text:
|
|
return text or ""
|
|
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
|
|
|
|
# System directories that should never have their permissions modified
|
|
# These directories have special system-level permissions that must be preserved
|
|
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
|
'/tmp',
|
|
'/var/tmp',
|
|
'/dev',
|
|
'/proc',
|
|
'/sys',
|
|
'/run',
|
|
'/var/run',
|
|
'/etc',
|
|
'/boot',
|
|
'/var',
|
|
'/usr',
|
|
'/lib',
|
|
'/lib64',
|
|
'/bin',
|
|
'/sbin',
|
|
}
|
|
|
|
|
|
def ensure_directory_permissions(path: Path, mode: int = 0o775) -> None:
|
|
"""
|
|
Create directory and set permissions.
|
|
|
|
If the directory already exists and we cannot change its permissions,
|
|
we check if it's usable (readable/writable). If so, we continue without
|
|
raising an exception. This allows the system to work even when running
|
|
as a non-root user who cannot change permissions on existing directories.
|
|
|
|
Protected system directories (like /tmp, /etc, /var) are never modified
|
|
to prevent breaking system functionality.
|
|
|
|
Args:
|
|
path: Directory path to create/ensure
|
|
mode: Permission mode (default: 0o775 for group-writable directories)
|
|
|
|
Raises:
|
|
OSError: If directory creation fails or directory exists but is not usable
|
|
"""
|
|
try:
|
|
# Never modify permissions on system directories
|
|
path_str = str(path.resolve() if path.is_absolute() else path)
|
|
if path_str in PROTECTED_SYSTEM_DIRECTORIES:
|
|
logger.debug(f"Skipping permission modification on protected system directory: {path_str}")
|
|
# Verify the directory is usable
|
|
if path.exists() and os.access(path, os.R_OK | os.W_OK):
|
|
return
|
|
elif path.exists():
|
|
logger.warning(f"Protected system directory {path_str} exists but is not writable")
|
|
return
|
|
else:
|
|
raise OSError(f"Protected system directory {path_str} does not exist")
|
|
|
|
# Create directory if it doesn't exist
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Try to set permissions
|
|
try:
|
|
os.chmod(path, mode)
|
|
logger.debug(f"Set directory permissions {oct(mode)} on {path}")
|
|
except (OSError, PermissionError) as perm_error:
|
|
# If we can't set permissions, check if directory is usable
|
|
if path.exists():
|
|
# Check if directory is readable and writable
|
|
if os.access(path, os.R_OK | os.W_OK):
|
|
logger.warning(
|
|
f"Could not set permissions on {path} (may be owned by different user), "
|
|
f"but directory is usable (readable/writable). Continuing."
|
|
)
|
|
return
|
|
else:
|
|
# Directory exists but is not usable
|
|
logger.error(
|
|
f"Directory {path} exists but is not readable/writable. "
|
|
f"Permission change failed: {perm_error}"
|
|
)
|
|
raise OSError(
|
|
f"Directory {path} exists but is not usable: {perm_error}"
|
|
) from perm_error
|
|
else:
|
|
# Directory doesn't exist and we couldn't create it
|
|
raise
|
|
except OSError as e:
|
|
logger.error(f"Failed to ensure directory {path}: {e}")
|
|
raise
|
|
|
|
|
|
def ensure_file_permissions(path: Path, mode: int = 0o644) -> None:
|
|
"""
|
|
Set file permissions after creation.
|
|
|
|
Args:
|
|
path: File path to set permissions on
|
|
mode: Permission mode (default: 0o644 for readable files)
|
|
|
|
Raises:
|
|
OSError: If permission setting fails
|
|
"""
|
|
try:
|
|
if path.exists():
|
|
os.chmod(path, mode)
|
|
logger.debug(f"Set file permissions {oct(mode)} on {path}")
|
|
else:
|
|
logger.warning(f"File does not exist, cannot set permissions: {path}")
|
|
except OSError as e:
|
|
logger.error(f"Failed to set file permissions on {path}: {e}")
|
|
raise
|
|
|
|
|
|
_shared_group_gid_cache: Optional[int] = None
|
|
|
|
|
|
def get_shared_group_gid() -> Optional[int]:
|
|
"""
|
|
Return the gid that should own config/secrets files shared between the
|
|
root-run ``ledmatrix.service`` (main display) and the non-root user that
|
|
``ledmatrix-web.service`` runs as (see install_web_service.sh, which sets
|
|
``User=$SUDO_USER``).
|
|
|
|
Resolved once from the project root directory's current group (normally
|
|
the login user's group from the initial ``git clone``), since that user
|
|
is stable across reinstalls unlike any single file's ownership.
|
|
|
|
Returns:
|
|
The gid, or None if it cannot be determined.
|
|
"""
|
|
global _shared_group_gid_cache
|
|
if _shared_group_gid_cache is not None:
|
|
return _shared_group_gid_cache
|
|
try:
|
|
project_root = Path(__file__).resolve().parent.parent.parent
|
|
_shared_group_gid_cache = project_root.stat().st_gid
|
|
return _shared_group_gid_cache
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def ensure_shared_group_ownership(path: Path) -> None:
|
|
"""
|
|
Best-effort chgrp of ``path`` to the shared group (see
|
|
:func:`get_shared_group_gid`) when running as root.
|
|
|
|
Only root can change a file's group to one the calling process isn't a
|
|
member of, which is exactly the case that causes the web interface
|
|
(running as a non-root user) to get ``PermissionError`` reading files
|
|
the root-run display service just wrote with a 0o640/2775 mode: the mode
|
|
is group-readable, but without this the group is root's, not the web
|
|
user's. Silently does nothing if not running as root or on any error —
|
|
this is a hardening step, not a required one.
|
|
|
|
``os.geteuid``/``os.chown`` only exist on POSIX. On Windows there is no
|
|
root and no shared group to move the file to, so the whole step is moot —
|
|
but looking the names up unguarded raises ``AttributeError``, which is not
|
|
an ``OSError`` and so escapes every caller's error handling. That took
|
|
``ConfigManager.load_config()`` down on any Windows checkout that has a
|
|
``config/config_secrets.json``, i.e. every developer machine that has ever
|
|
run the app, and with it the import of ``web_interface.app``.
|
|
"""
|
|
if not hasattr(os, 'geteuid') or not hasattr(os, 'chown'):
|
|
return
|
|
if os.geteuid() != 0:
|
|
return
|
|
gid = get_shared_group_gid()
|
|
if gid is None:
|
|
return
|
|
try:
|
|
if path.exists() and path.stat().st_gid != gid:
|
|
os.chown(path, -1, gid)
|
|
logger.debug(f"Set shared group ownership (gid {gid}) on {path}")
|
|
except OSError as e:
|
|
logger.debug(f"Could not set shared group ownership on {path}: {e}")
|
|
|
|
|
|
def get_config_file_mode(file_path: Path) -> int:
|
|
"""
|
|
Return appropriate permission mode for config files.
|
|
|
|
Args:
|
|
file_path: Path to config file
|
|
|
|
Returns:
|
|
Permission mode: 0o640 for secrets files, 0o644 for regular config
|
|
"""
|
|
if 'secrets' in str(file_path):
|
|
return 0o640 # rw-r-----
|
|
else:
|
|
return 0o644 # rw-r--r--
|
|
|
|
|
|
def get_assets_file_mode() -> int:
|
|
"""
|
|
Return permission mode for asset files (logos, images, etc.).
|
|
|
|
Returns:
|
|
Permission mode: 0o664 (rw-rw-r--) for group-writable assets
|
|
"""
|
|
return 0o664 # rw-rw-r--
|
|
|
|
|
|
def get_assets_dir_mode() -> int:
|
|
"""
|
|
Return permission mode for asset directories.
|
|
|
|
Returns:
|
|
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
|
entries created in it take the directory's group
|
|
"""
|
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
|
|
|
|
|
def get_config_dir_mode() -> int:
|
|
"""
|
|
Return permission mode for config directory.
|
|
|
|
Returns:
|
|
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
|
entries created in it take the directory's group
|
|
"""
|
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
|
|
|
|
|
def get_plugin_file_mode() -> int:
|
|
"""
|
|
Return permission mode for plugin files.
|
|
|
|
Returns:
|
|
Permission mode: 0o664 (rw-rw-r--) for group-writable plugin files
|
|
"""
|
|
return 0o664 # rw-rw-r--
|
|
|
|
|
|
def get_plugin_dir_mode() -> int:
|
|
"""
|
|
Return permission mode for plugin directories.
|
|
|
|
Returns:
|
|
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
|
entries created in it take the directory's group
|
|
"""
|
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
|
|
|
|
|
def get_cache_dir_mode() -> int:
|
|
"""
|
|
Return permission mode for cache directories.
|
|
|
|
Returns:
|
|
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
|
entries created in it take the directory's group
|
|
"""
|
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
|
|
|
|
|
def _sudo_bash_candidates() -> list:
|
|
"""Bash paths to try, in order, when running a vetted helper via sudo.
|
|
|
|
sudoers matches the exact argv, so ``sudo -n <bash> <helper> ...`` only
|
|
works if <bash> is the same path configure_web_sudo.sh wrote into the
|
|
rule -- whatever ``command -v bash`` said on the machine that ran it.
|
|
On merged-/usr systems /usr/bin/bash and /bin/bash are the same file but
|
|
different strings to sudo, and the web user's PATH can differ from the
|
|
installer's, so no single guess is reliable. Callers try each in turn and
|
|
move on only when sudo refused the command line (SUDO_REFUSAL_PHRASES).
|
|
The helper is invoked through bash rather than its shebang for the same
|
|
reason: the rule names bash, not the script.
|
|
"""
|
|
candidates = []
|
|
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
|
|
if candidate and candidate not in candidates:
|
|
candidates.append(candidate)
|
|
return candidates
|
|
|
|
|
|
def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> bool:
|
|
"""
|
|
Remove a directory using sudo as a last resort.
|
|
|
|
Used when normal removal fails due to root-owned files (e.g., __pycache__
|
|
directories created by the root ledmatrix service). Delegates to the
|
|
safe_plugin_rm.sh helper which validates the path is inside allowed
|
|
plugin directories.
|
|
|
|
Before invoking sudo, this function also validates that the resolved
|
|
path is a descendant of at least one allowed base directory.
|
|
|
|
Args:
|
|
path: Directory path to remove
|
|
allowed_bases: List of allowed parent directories. If None, defaults
|
|
to plugin-repos/ and plugins/ under the project root.
|
|
|
|
Returns:
|
|
True if removal succeeded, False otherwise
|
|
"""
|
|
# Determine project root (permission_utils.py is at src/common/)
|
|
project_root = Path(__file__).resolve().parent.parent.parent
|
|
|
|
if allowed_bases is None:
|
|
allowed_bases = [
|
|
project_root / "plugin-repos",
|
|
project_root / "plugins",
|
|
]
|
|
|
|
# Resolve the target path to prevent symlink/traversal tricks
|
|
try:
|
|
resolved = path.resolve()
|
|
except (OSError, ValueError) as e:
|
|
logger.error(f"Cannot resolve path {path}: {e}")
|
|
return False
|
|
|
|
# Validate the resolved path is a strict child of an allowed base
|
|
is_allowed = False
|
|
for base in allowed_bases:
|
|
try:
|
|
base_resolved = base.resolve()
|
|
if resolved != base_resolved and resolved.is_relative_to(base_resolved):
|
|
is_allowed = True
|
|
break
|
|
except (OSError, ValueError):
|
|
continue
|
|
|
|
if not is_allowed:
|
|
logger.error(
|
|
f"sudo_remove_directory DENIED: {resolved} is not inside "
|
|
f"allowed bases {[str(b) for b in allowed_bases]}"
|
|
)
|
|
return False
|
|
|
|
# Use the safe_plugin_rm.sh helper which does its own validation
|
|
helper_script = project_root / "scripts" / "fix_perms" / "safe_plugin_rm.sh"
|
|
if not helper_script.exists():
|
|
logger.error(f"Safe removal helper not found: {helper_script}")
|
|
return False
|
|
|
|
try:
|
|
for bash_path in _sudo_bash_candidates():
|
|
result = subprocess.run(
|
|
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
if result.returncode == 0 and not resolved.exists():
|
|
logger.info(f"Successfully removed {path} via sudo helper")
|
|
return True
|
|
# Only a refused command line is worth another bash path; if the
|
|
# helper itself ran and failed, a retry would just repeat it.
|
|
if result.returncode == 0 or not any(
|
|
phrase in (result.stderr or '') for phrase in SUDO_REFUSAL_PHRASES):
|
|
break
|
|
stderr = (result.stderr or '').strip()
|
|
logger.error(f"sudo helper failed for {path}: {stderr}")
|
|
return False
|
|
except subprocess.TimeoutExpired:
|
|
logger.error(f"sudo helper timed out for {path}")
|
|
return False
|
|
except FileNotFoundError:
|
|
logger.error("sudo command not found on system")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
|
|
return False
|
|
|
|
|
|
#: What sudo prints when it refuses a command line outright (not in sudoers for
|
|
#: that exact argv, or it wants a password). Only then is another bash path
|
|
#: worth trying: after pip itself ran, a retry just repeats the failure. The
|
|
#: automatic update's health check keeps its own copy of this list
|
|
#: (scripts/utils/auto_update_verify.py runs without importing src/).
|
|
SUDO_REFUSAL_PHRASES = ("a password is required", "is not allowed to run", "no tty present")
|
|
|
|
|
|
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
|
|
"""
|
|
Install a requirements.txt file for a plugin (or the project itself).
|
|
|
|
Prefers the vetted sudo wrapper (scripts/fix_perms/safe_pip_install.sh) so
|
|
packages end up visible to root-run ledmatrix.service, not just to
|
|
whichever non-root user happens to run the calling process (e.g. the web
|
|
interface). Falls back to installing with the calling process's own
|
|
interpreter if the wrapper isn't set up yet (the admin hasn't run
|
|
scripts/install/configure_web_sudo.sh), so dependency installation still
|
|
does *something* useful rather than hard-failing.
|
|
|
|
Always installs with the interpreter that will actually run the code
|
|
(``sys.executable`` in the fallback path, the wrapper's ``python3`` in the
|
|
sudo path) rather than a bare ``pip``/``pip3`` off PATH, which can
|
|
silently resolve to a different Python installation (e.g. system Python
|
|
vs. a virtualenv) than the one importing the package at runtime.
|
|
|
|
Args:
|
|
req_file: Path to a requirements.txt file
|
|
timeout: Subprocess timeout in seconds
|
|
|
|
Returns:
|
|
subprocess.CompletedProcess from the pip (or wrapper) invocation.
|
|
Never raises on a non-zero exit; callers should check ``returncode``.
|
|
``stdout`` is prefixed with an explanatory note when the root wrapper
|
|
was unavailable and the fallback path was used.
|
|
"""
|
|
project_root = Path(__file__).resolve().parent.parent.parent
|
|
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
|
|
|
|
if wrapper.exists():
|
|
# See _sudo_bash_candidates for why bash is invoked by explicit path
|
|
# and why there is more than one to try.
|
|
result = None
|
|
for bash_path in _sudo_bash_candidates():
|
|
# bash_path and wrapper are fixed, known-good paths, and
|
|
# safe_pip_install.sh independently re-validates req_file is an
|
|
# allowed requirements.txt before installing anything as root.
|
|
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
|
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
|
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
|
)
|
|
# Redact immediately: pip can echo a private index URL's embedded
|
|
# basic-auth credentials back in its own error/progress output
|
|
# (e.g. from a requirements.txt --index-url line). Doesn't affect
|
|
# the fixed-phrase "denied" check below -- those phrases never
|
|
# overlap with URL syntax.
|
|
result.stderr = _redact_url_credentials(result.stderr)
|
|
result.stdout = _redact_url_credentials(result.stdout)
|
|
if result.returncode == 0:
|
|
return result
|
|
# Distinguish "sudo rejected this exact command line" (worth
|
|
# trying the next bash candidate) from "sudo ran it but pip
|
|
# itself failed" (a real error — stop and surface it).
|
|
denied = any(phrase in result.stderr for phrase in SUDO_REFUSAL_PHRASES)
|
|
if not denied:
|
|
# Deliberately don't interpolate req_file or the pip output here:
|
|
# this log line is scanner-visible, and a static analyzer can't
|
|
# tell "already redacted above" from "still raw" just by looking
|
|
# at this call in isolation. The full (redacted) text is still
|
|
# available to callers via the returned CompletedProcess.
|
|
logger.warning(
|
|
"Root pip install failed (rc=%s); see the returned "
|
|
"CompletedProcess.stderr for details.",
|
|
result.returncode,
|
|
)
|
|
return result
|
|
|
|
# Same reasoning as above: no req_file / pip-output interpolation in
|
|
# this log line, only in the returned note/CompletedProcess.
|
|
logger.warning(
|
|
"Root pip install wrapper denied via sudo for all candidates; "
|
|
"falling back to user-level install. See the returned "
|
|
"CompletedProcess.stderr for details."
|
|
)
|
|
note = (
|
|
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
|
|
"installed for the current process'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:
|
|
logger.warning(
|
|
"safe_pip_install.sh not found; falling back to user-level install."
|
|
)
|
|
note = (
|
|
"[safe_pip_install.sh not found; installed for the current process's "
|
|
"user only. Run scripts/install/configure_web_sudo.sh to enable "
|
|
"root installs visible to ledmatrix.service.]\n"
|
|
)
|
|
|
|
# sys.executable is this process's own interpreter (not
|
|
# attacker-influenced), and req_file is a Path built internally by callers
|
|
# (store_manager.py plugin paths, PROJECT_ROOT/requirements.txt), never
|
|
# raw external/user input. --ignore-installed matches safe_pip_install.sh:
|
|
# apt-managed packages (e.g. python3-requests) ship no pip RECORD file, so
|
|
# upgrading them would otherwise abort with "uninstall-no-record-file".
|
|
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
|
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
|
|
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
|
)
|
|
result.stderr = _redact_url_credentials(result.stderr)
|
|
result.stdout = note + _redact_url_credentials(result.stdout)
|
|
return result
|
|
|