fix(web): say when a system action failed for want of passwordless sudo (#560)

* fix(web): say when a system action failed for want of passwordless sudo

POSTing reboot_system to a Pi returns, in full:

  {"message": "Action failed; see logs for details", "status": "error"}

The cause is that the web interface runs unprivileged, and its
systemctl/reboot/journalctl calls only work once
scripts/install/configure_web_sudo.sh has granted NOPASSWD. first_time_install.sh
never invokes that script and no user-facing doc mentions it, so on a fresh
device every privileged action fails -- start_display, stop_display, the
autostart toggles, reboot, and the log viewer.

That last one closes the loop: "see logs for details" is unreachable advice
when journalctl is refused for the same reason. This is exactly the failure
src/web_interface/error_handler.py's describe_exception() was written to break,
and /system/action's exception handler was still discarding the cause instead
of using the helper the module already imports.

Two changes, no behaviour change when things work:

- The exception path now returns 'details': describe_exception(e), matching how
  the other handlers in this blueprint already report.
- A failure whose stderr or exception text is sudo refusing to prompt ("a
  password is required", "no tty present", "a terminal is required") reports
  what to do about it, naming configure_web_sudo.sh. Unrelated failures keep
  the generic message and their stderr, so a missing unit is not blamed on
  sudo.

Granting the sudo rights is left alone deliberately: auto-running a script that
hands out NOPASSWD is a security decision for the maintainer, not something to
slip into an installer. Making the refusal legible is the part that is
unambiguously an improvement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): apply the sudo hint on the on-demand start_display path too

start_display with a mode builds its own response and returns before the shared
nonzero-result path, so a recognized sudo refusal there reported only "Failed to
start display" and said nothing about the passwordless sudo that refused it --
the exact gap the rest of this PR closes everywhere else.

Raised by CodeRabbit on #560 and verified against the code before fixing: the
branch at api_v3.py:2058 does return early past the shared handler.

Three regression cases: the on-demand branch reports the sudo cause, keeps its
"Display started" message on success, and does not blame an unrelated failure on
sudo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-11 08:45:40 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 39f27d285d
commit 0ab95586fb
2 changed files with 176 additions and 2 deletions
+45 -2
View File
@@ -1979,6 +1979,33 @@ def check_for_update():
return jsonify(_update_check_failed(
"Could not check for updates; see logs for details."))
#: sudo's own wording when it needs a password it cannot ask for. The web
#: interface runs unprivileged, so its systemctl/reboot/journalctl calls only
#: work once scripts/install/configure_web_sudo.sh has granted NOPASSWD --
#: which first_time_install.sh does not do. That makes this the common case on
#: a fresh device, and "Action failed; see logs for details" named none of it,
#: while the log viewer was broken for the very same reason.
_SUDO_NEEDS_PASSWORD = (
'a password is required',
'no tty present',
'a terminal is required',
)
_SUDO_HINT = (
'Passwordless sudo is not configured for the web interface user, so this '
'action cannot run. Run scripts/install/configure_web_sudo.sh as that user, '
'then retry.'
)
def _sudo_hint_for(text):
"""An actionable hint when `text` is sudo refusing to prompt, else None."""
lowered = (text or '').lower()
if any(marker in lowered for marker in _SUDO_NEEDS_PASSWORD):
return _SUDO_HINT
return None
@api_v3.route('/system/action', methods=['POST'])
def execute_system_action():
"""Execute system actions (start/stop/reboot/etc)"""
@@ -2014,7 +2041,14 @@ def execute_system_action():
logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip())
resp = {
'status': 'success' if result.returncode == 0 else 'error',
'message': 'Display started' if result.returncode == 0 else 'Failed to start display',
# This branch returns before the shared nonzero-result
# response below, so it needs the hint of its own or an
# on-demand start reports "Failed to start display" and
# says nothing about the sudo that actually refused it.
'message': (
'Display started' if result.returncode == 0
else _sudo_hint_for(result.stderr) or 'Failed to start display'
),
}
if result.returncode != 0:
resp['returncode'] = result.returncode
@@ -2346,6 +2380,9 @@ def execute_system_action():
if result.returncode != 0:
resp['returncode'] = result.returncode
resp['stderr'] = result.stderr.strip()
hint = _sudo_hint_for(result.stderr)
if hint:
resp['message'] = hint
return jsonify(resp)
except subprocess.TimeoutExpired as e:
@@ -2353,7 +2390,13 @@ def execute_system_action():
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
except Exception as e:
logger.error("execute_system_action failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
detail = describe_exception(e)
resp = {
'status': 'error',
'message': _sudo_hint_for(detail) or 'Action failed; see logs for details',
'details': detail,
}
return jsonify(resp), 500
@api_v3.route('/system/git-info', methods=['GET'])
def get_git_info():