diff --git a/test/web_interface/test_systemctl_sudoers_alignment.py b/test/web_interface/test_systemctl_sudoers_alignment.py new file mode 100644 index 00000000..f29691f0 --- /dev/null +++ b/test/web_interface/test_systemctl_sudoers_alignment.py @@ -0,0 +1,76 @@ +"""Guards that every privileged systemctl call the web interface makes is +covered by a passwordless-sudo grant in configure_web_sudo.sh. + +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 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py" +SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.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 ` 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.read_text()) + 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 " + "configure_web_sudo.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 .service so they + match the sudoers grants, which use the fully-qualified unit name.""" + calls = _sudo_systemctl_calls(API_V3.read_text()) + 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)) + ) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index d278312a..4c954b00 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -190,7 +190,7 @@ def _ensure_display_service_running(): if status.get('active'): status['started'] = False return status - result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix']) + result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service']) service_status = _get_display_service_status() result['started'] = result.get('returncode') == 0 result['active'] = service_status.get('active') @@ -199,7 +199,7 @@ def _ensure_display_service_running(): def _stop_display_service(): """Stop the ledmatrix display service.""" - result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix']) + result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service']) status = _get_display_service_status() result['active'] = status.get('active') result['status'] = status @@ -1461,7 +1461,7 @@ def execute_system_action(): # For on-demand modes, we would need to integrate with the display controller # For now, just start the display service try: - result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'], + result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'], capture_output=True, text=True, timeout=10) except subprocess.TimeoutExpired as e: logger.error("start_display (%s) timed out: %s", mode, e) @@ -1478,16 +1478,16 @@ def execute_system_action(): resp['stderr'] = result.stderr.strip() return jsonify(resp) else: - result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'], + result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'], capture_output=True, text=True, timeout=10) elif action == 'stop_display': - result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'], + result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'], capture_output=True, text=True, timeout=10) elif action == 'enable_autostart': - result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'], + result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'], capture_output=True, text=True, timeout=10) elif action == 'disable_autostart': - result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'], + result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'], capture_output=True, text=True, timeout=10) elif action == 'reboot_system': result = subprocess.run(['sudo', 'reboot'], @@ -1568,11 +1568,11 @@ def execute_system_action(): 'message': pull_message, }) elif action == 'restart_display_service': - result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'], + result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'], capture_output=True, text=True, timeout=10) elif action == 'restart_web_service': # Try to restart the web service (assuming it's ledmatrix-web.service) - result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'], + result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'], capture_output=True, text=True, timeout=10) else: return jsonify({'status': 'error', 'message': 'Unknown action'}), 400