mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* refactor(install): generate the web sudoers rules in one place /etc/sudoers.d/ledmatrix_web was written by two copies of the same allow-list: a heredoc in first_time_install.sh Step 10 and a block of echo lines in scripts/install/configure_web_sudo.sh. They drifted before (safe_pip_install.sh was granted by one only), and a test existed just to catch that. Both now call web_sudoers_rules() from the new scripts/install/lib_sudoers.sh and keep their own validate (visudo -c), install and confirm flows. - first_time_install.sh output is byte-for-byte unchanged, so a device re-running the installer gets "already up to date". If the library is missing, Step 10 keeps the installed file and carries on, the same way it handles rules that fail visudo (an empty file would pass visudo). - configure_web_sudo.sh now writes the installer's layout: same 18 rules, different comments and order. It still leaves out reboot, poweroff and journalctl when they are missing; the library does that for both. The drift test now pins the generator's grants, checks that neither installer writes rules of its own, and runs each installer's call line to check the argument order. Tests that read the rule text now read the library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(install): detect the web service user in one function first_time_install.sh pasted the same WEB_SERVICE_USER detection block three times (Step 3.1's fallback, the plugin-repos setup and Step 11). The copies were identical apart from comments; they now call detect_web_service_user(), whose body is that block unchanged. Behaviour is the same: the function sets the same global and always returns 0, as the inline if-chain did. Checked on Linux against all three original copies across 13 layouts (installed unit with and without User=, the repo as shipped, each grep branch, template placeholders). The comment notes that the install_web_service.sh / install_service.sh greps no longer match anything, so until Step 8 installs the unit the result is "root". That behaviour is left as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
83 lines
3.4 KiB
Python
83 lines
3.4 KiB
Python
"""Guards that every privileged systemctl call the web interface makes is
|
|
covered by a passwordless-sudo grant in scripts/install/lib_sudoers.sh, which
|
|
both first_time_install.sh and configure_web_sudo.sh write the rules from.
|
|
|
|
The web interface runs headless (no TTY), so any `sudo` call that is not
|
|
matched by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web falls back to a
|
|
password prompt and fails with:
|
|
|
|
sudo: a terminal is required to read the password
|
|
|
|
sudo matches the command line by exact string, so `systemctl start ledmatrix`
|
|
and `systemctl start ledmatrix.service` are NOT interchangeable. This test
|
|
parses both the production blueprint and the sudoers-generator script and
|
|
asserts the (verb, unit) pairs line up, catching the suffix-mismatch class of
|
|
bug before it ships.
|
|
"""
|
|
|
|
import ast
|
|
import re
|
|
from pathlib import Path
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
# api_v3 is a package; a sudo systemctl call can live in any of its modules.
|
|
API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3"
|
|
|
|
|
|
def _api_v3_source() -> str:
|
|
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
|
|
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "lib_sudoers.sh"
|
|
|
|
|
|
def _sudo_systemctl_calls(source: str) -> set[tuple[str, str]]:
|
|
"""Return (verb, unit) for every list literal beginning with
|
|
['sudo', 'systemctl', ...] passed to a subprocess call in the source."""
|
|
calls: set[tuple[str, str]] = set()
|
|
for node in ast.walk(ast.parse(source)):
|
|
if not isinstance(node, ast.List):
|
|
continue
|
|
elts = node.elts
|
|
if len(elts) < 4:
|
|
continue
|
|
if not all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in elts[:4]):
|
|
continue
|
|
if elts[0].value == "sudo" and elts[1].value == "systemctl":
|
|
calls.add((elts[2].value, elts[3].value))
|
|
return calls
|
|
|
|
|
|
def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
|
|
"""Return (verb, unit) for each `$SYSTEMCTL_PATH <verb> <unit>` NOPASSWD
|
|
grant emitted by the sudoers-generator script."""
|
|
rules: set[tuple[str, str]] = set()
|
|
for match in re.finditer(r"\$SYSTEMCTL_PATH\s+(\S+)\s+(\S+)", script):
|
|
verb, unit = match.group(1), match.group(2).rstrip('"')
|
|
rules.add((verb, unit))
|
|
return rules
|
|
|
|
|
|
def test_every_sudo_systemctl_call_is_granted() -> None:
|
|
calls = _sudo_systemctl_calls(_api_v3_source())
|
|
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
|
|
|
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
|
|
|
uncovered = {c for c in calls if c not in rules}
|
|
assert not uncovered, (
|
|
"These sudo systemctl calls have no matching NOPASSWD grant in "
|
|
"lib_sudoers.sh; they will fail headless with "
|
|
"'sudo: a terminal is required to read the password': "
|
|
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(uncovered))
|
|
)
|
|
|
|
|
|
def test_units_are_fully_qualified() -> None:
|
|
"""Privileged systemctl calls must name the unit as <name>.service so they
|
|
match the sudoers grants, which use the fully-qualified unit name."""
|
|
calls = _sudo_systemctl_calls(_api_v3_source())
|
|
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
|
assert not unqualified, (
|
|
"sudo systemctl calls must use fully-qualified .service unit names: "
|
|
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(unqualified))
|
|
)
|