mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
Merge audit16-core/autoupdate into the September 16 core audit fixes
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
#!/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
|
||||
# that the resolved path is one of the project's own requirements files
|
||||
# (requirements.txt, web_interface/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
|
||||
@@ -25,9 +26,17 @@ 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
|
||||
# - the project's own requirements files. Update Code, the automatic
|
||||
# update's health check and Install Base Requirements install both, so
|
||||
# one missing here is refused on every device -- and the automatic
|
||||
# updater rolls back any update that changes it.
|
||||
# Only their folders are resolved: resolving the files too would follow
|
||||
# a requirements.txt symlinked out of the project and allow its target.
|
||||
# - any requirements.txt under plugin-repos/ or plugins/
|
||||
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
|
||||
ALLOWED_EXACT=(
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT")/requirements.txt"
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/web_interface")/requirements.txt"
|
||||
)
|
||||
ALLOWED_BASES=(
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
|
||||
@@ -43,9 +52,13 @@ if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
|
||||
fi
|
||||
|
||||
ALLOWED=false
|
||||
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
|
||||
ALLOWED=true
|
||||
else
|
||||
for EXACT in "${ALLOWED_EXACT[@]}"; do
|
||||
if [ "$RESOLVED_TARGET" = "$EXACT" ]; then
|
||||
ALLOWED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
for BASE in "${ALLOWED_BASES[@]}"; do
|
||||
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
|
||||
ALLOWED=true
|
||||
@@ -56,7 +69,7 @@ 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
|
||||
echo "Allowed: ${ALLOWED_EXACT[*]}, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
|
||||
@@ -42,10 +42,30 @@ HEALTH_TIMEOUT_SECONDS = 180
|
||||
#: loop look healthy between attempts, so a single "is-active" proves nothing.
|
||||
STABLE_SECONDS = 45
|
||||
POLL_SECONDS = 5
|
||||
WEB_CHECK_TIMEOUT_SECONDS = 5
|
||||
SYSTEMCTL_QUERY_TIMEOUT_SECONDS = 10
|
||||
RESTART_TIMEOUT_SECONDS = 90
|
||||
GIT_TIMEOUT_SECONDS = 60
|
||||
GIT_RESET_TIMEOUT_SECONDS = 120
|
||||
PIP_TIMEOUT_SECONDS = 600
|
||||
#: All of a rollback's dependency reinstalls together. A pip that times out
|
||||
#: or fails is not retried: systemd stops this unit at TimeoutStartSec, and a
|
||||
#: rollback killed half-way leaves the update reported as still verifying.
|
||||
PIP_BUDGET_SECONDS = 600
|
||||
#: sudoers matches the exact command line, so bash is named by path, the same
|
||||
#: candidates src/common/permission_utils.install_requirements_file tries.
|
||||
#: candidates src/common/permission_utils.install_requirements_file tries...
|
||||
BASH_CANDIDATES = ('/usr/bin/bash', '/bin/bash')
|
||||
#: ...and, like it, moves to the next one only when sudo refused the command
|
||||
#: line (permission_utils.SUDO_REFUSAL_PHRASES), never after pip itself ran.
|
||||
SUDO_REFUSAL_PHRASES = ('a password is required', 'is not allowed to run', 'no tty present')
|
||||
|
||||
#: The longest one health check can take: restart and wait, roll back
|
||||
#: (diff, reset, reinstalls), restart and wait again. A wait's last poll can
|
||||
#: start just before its deadline and run every query to its timeout.
|
||||
_WAIT_WORST_SECONDS = (HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS + WEB_CHECK_TIMEOUT_SECONDS
|
||||
+ 2 * SYSTEMCTL_QUERY_TIMEOUT_SECONDS + POLL_SECONDS)
|
||||
WORST_CASE_SECONDS = (2 * (2 * RESTART_TIMEOUT_SECONDS + _WAIT_WORST_SECONDS)
|
||||
+ GIT_TIMEOUT_SECONDS + GIT_RESET_TIMEOUT_SECONDS + PIP_BUDGET_SECONDS)
|
||||
|
||||
#: What a command that could not run at all reports: its callers only read
|
||||
#: these three fields, the same ones a completed subprocess has.
|
||||
@@ -83,7 +103,7 @@ def write_pending(path, data):
|
||||
|
||||
def _web_responds(url=WEB_HEALTH_URL):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310 - fixed loopback URL
|
||||
with urllib.request.urlopen(url, timeout=WEB_CHECK_TIMEOUT_SECONDS) as resp: # nosec B310 - fixed loopback URL
|
||||
return resp.status == 200
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return False
|
||||
@@ -104,7 +124,7 @@ class Verifier:
|
||||
self.web_responds = web_responds
|
||||
self.log = log or (lambda msg: print(f'[auto-update-verify] {msg}', flush=True))
|
||||
|
||||
def _run(self, args, timeout=60):
|
||||
def _run(self, args, timeout=GIT_TIMEOUT_SECONDS):
|
||||
try:
|
||||
return self.run(args, cwd=str(self.project_root), capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
@@ -114,15 +134,17 @@ class Verifier:
|
||||
# -- services ---------------------------------------------------------
|
||||
|
||||
def service_active(self, unit):
|
||||
return self._run(['systemctl', 'is-active', unit], timeout=10).stdout.strip() == 'active'
|
||||
return self._run(['systemctl', 'is-active', unit],
|
||||
timeout=SYSTEMCTL_QUERY_TIMEOUT_SECONDS).stdout.strip() == 'active'
|
||||
|
||||
def restart_count(self, unit):
|
||||
out = self._run(['systemctl', 'show', '-p', 'NRestarts', '--value', unit],
|
||||
timeout=10).stdout.strip()
|
||||
timeout=SYSTEMCTL_QUERY_TIMEOUT_SECONDS).stdout.strip()
|
||||
return int(out) if out.isdigit() else None
|
||||
|
||||
def restart(self, unit):
|
||||
result = self._run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'], timeout=90)
|
||||
result = self._run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'],
|
||||
timeout=RESTART_TIMEOUT_SECONDS)
|
||||
if result.returncode != 0:
|
||||
self.log(f'restarting {unit} failed: {(result.stderr or "").strip()}')
|
||||
return result.returncode == 0
|
||||
@@ -173,16 +195,28 @@ class Verifier:
|
||||
changed = set(result.stdout.split()) if result.returncode == 0 else set(REQUIREMENT_FILES)
|
||||
return [rel for rel in REQUIREMENT_FILES if rel in changed]
|
||||
|
||||
def install_requirements(self, rel):
|
||||
def install_requirements(self, rel, deadline=None):
|
||||
"""Install one requirements file through the root wrapper, by ``deadline``."""
|
||||
wrapper = self.project_root / 'scripts' / 'fix_perms' / 'safe_pip_install.sh'
|
||||
req = self.project_root / rel
|
||||
if not req.exists():
|
||||
return True
|
||||
for bash in BASH_CANDIDATES:
|
||||
result = self._run(['sudo', '-n', bash, str(wrapper), str(req)],
|
||||
timeout=PIP_TIMEOUT_SECONDS)
|
||||
timeout = PIP_TIMEOUT_SECONDS
|
||||
if deadline is not None:
|
||||
timeout = min(timeout, deadline - self.clock())
|
||||
if timeout <= 0:
|
||||
self.log(f'no time left to reinstall {rel}')
|
||||
return False
|
||||
result = self._run(['sudo', '-n', bash, str(wrapper), str(req)], timeout=timeout)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
# Only a refused command line is worth the next candidate. A pip
|
||||
# that ran and failed, or timed out, would just do it again.
|
||||
if not any(phrase in (result.stderr or '') for phrase in SUDO_REFUSAL_PHRASES):
|
||||
# Not pip's output: it can echo an index URL's credentials.
|
||||
self.log(f'reinstalling {rel} failed (exit {result.returncode})')
|
||||
return False
|
||||
return False
|
||||
|
||||
def rollback(self, pending):
|
||||
@@ -191,13 +225,17 @@ class Verifier:
|
||||
if not old:
|
||||
return False, 'the commit to roll back to is unknown'
|
||||
requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES)
|
||||
# Safe to --hard: the updater refuses to run with local edits to
|
||||
# tracked files, so the only thing this discards is the update.
|
||||
result = self._run(['git', 'reset', '--hard', old], timeout=120)
|
||||
# --hard: the updater refuses to run with local edits to tracked core
|
||||
# files (web_interface/auto_update.local_changes), so outside the
|
||||
# plugin folders the only thing this discards is the update. Edits
|
||||
# under plugins/ and plugin-repos/, which that check leaves to the
|
||||
# pull's --autostash, are reset along with it.
|
||||
result = self._run(['git', 'reset', '--hard', old], timeout=GIT_RESET_TIMEOUT_SECONDS)
|
||||
if result.returncode != 0:
|
||||
return False, (f'"git reset --hard {old}" failed: '
|
||||
f'{(result.stderr or result.stdout or "").strip()}')
|
||||
failed = [rel for rel in requirements if not self.install_requirements(rel)]
|
||||
deadline = self.clock() + PIP_BUDGET_SECONDS
|
||||
failed = [rel for rel in requirements if not self.install_requirements(rel, deadline)]
|
||||
if failed:
|
||||
return True, ('reinstalling the previous dependencies from ' + ', '.join(failed)
|
||||
+ ' failed; run Install Base Requirements from the Tools tab')
|
||||
|
||||
Reference in New Issue
Block a user