Merge audit16-core/autoupdate into the September 16 core audit fixes

This commit is contained in:
Chuck
2026-09-17 09:18:01 -04:00
10 changed files with 581 additions and 70 deletions
+20 -7
View File
@@ -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
+51 -13
View File
@@ -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')