Files
ServerKit/backend/app/utils/system.py
T
Juan DenisandClaude Opus 5 81928ec7ff fix(ssl): resolve certbot outside $PATH and preflight the nginx plugin
Plan 73 item 1. SSLService hardcoded /usr/bin/certbot, so the install method
certbot itself recommends -- snap, which lands in /snap/bin -- produced a
FileNotFoundError the panel surfaced as an opaque failure. is_command_available
had the same blind spot: it probed /usr/bin, /usr/sbin and the two local dirs
but never /snap/bin, which is routinely absent from a service's $PATH.

resolve_certbot_bin() now tries the CERTBOT_BIN override, then shutil.which,
then the explicit candidate list including the snap path. Positive results are
cached (an install location does not move mid-process) but negative ones are
not, so installing certbot is picked up without a panel restart;
install_certbot clears the cache outright. CERTBOT_BIN as a class attribute is
gone -- it captured the value at import time, which is exactly why an override
set later never took effect -- replaced by certbot_bin() plus CERTBOT_FALLBACK
so a command built on a host with no certbot still fails cleanly.

The second half is the failure the reviewer actually hit on stream: with
certbot present but its nginx plugin missing, `certbot --nginx` dies deep
inside certbot with "the nginx plugin is not working / binary doesn't exist",
and we passed that through raw. obtain_certificate now preflights -- nginx on
the box, then `certbot plugins` for an enabled `* nginx` -- and returns an
actionable error naming python3-certbot-nginx before certbot is ever invoked
for issuance. A failing plugins probe falls back to the distro package check
rather than blocking issuance on a host where the plugin is clearly present.

AdvancedSSLService's wildcard and multi-domain commands go through the same
resolution instead of the bare name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:59:47 -04:00

285 lines
10 KiB
Python

"""Centralized system utilities for subprocess handling.
Provides consistent privilege escalation, distro detection, package management,
and systemd service control so individual services don't need to reinvent these.
"""
import os
import shutil
import subprocess
from typing import List, Optional, Union
def _needs_sudo() -> bool:
"""Return True if the current process should prepend sudo to commands.
Returns False when:
- Running on Windows (no sudo concept; dev environment)
- Already running as root (e.g. inside Docker)
- ``sudo`` is not installed (minimal containers)
"""
if os.name == 'nt':
return False
if os.geteuid() == 0:
return False
if not shutil.which('sudo'):
return False
return True
def privileged_cmd(cmd: Union[List[str], str], *, user: Optional[str] = None) -> Union[List[str], str]:
"""Return *cmd* with ``sudo`` prepended when necessary.
Use this when you need the command list for ``Popen`` or other non-``run``
callers. For simple ``subprocess.run`` calls prefer :func:`run_privileged`.
Pass *user* to run the command as a specific user (``sudo -u <user>``).
``sudo -n`` (non-interactive) is always used: nothing here runs attached to a
human terminal, and callers capture output, so a password prompt would be
invisible AND unanswerable — sudo would simply block forever, hanging the
caller (this hung backend startup on a non-root host, via the metadata
guard's iptables probe). Failing immediately with a non-zero exit is the
only useful outcome, and every caller already handles that.
"""
if isinstance(cmd, str):
if _needs_sudo() and not cmd.lstrip().startswith('sudo '):
if user:
return f'sudo -n -u {user} {cmd}'
return f'sudo -n {cmd}'
return cmd
cmd = list(cmd)
if _needs_sudo() and cmd[0] != 'sudo':
if user:
return ['sudo', '-n', '-u', user] + cmd
return ['sudo', '-n'] + cmd
return cmd
# Ceiling for a privileged command that does not name its own. Generous enough
# for the slow-but-legitimate work that runs through here (package installs,
# image pulls), while guaranteeing no single call can wedge a worker forever.
# Anything genuinely longer must say so explicitly with `timeout=`.
DEFAULT_PRIVILEGED_TIMEOUT = 300
# Read-only status probes (is this package installed, is this unit active).
# They answer immediately or not at all, so they get a much tighter ceiling.
PROBE_TIMEOUT = 30
def run_privileged(cmd: Union[List[str], str], *, user: Optional[str] = None, **kwargs) -> subprocess.CompletedProcess:
"""Run a command with sudo if the current process is not root.
Prepends ``sudo`` only when needed (not root, not Windows, sudo exists).
Pass *user* to run the command as a specific user (``sudo -u <user>``).
Defaults to ``capture_output=True, text=True`` but callers can override.
A default ``timeout`` is applied when the caller does not give one: with
output captured and no terminal, a command that never returns takes its
caller with it silently and forever. ``TimeoutExpired`` is a far better
outcome than a wedged request or a boot that never finishes — pass
``timeout=None`` to opt out deliberately.
Returns the raw ``CompletedProcess`` so services keep their existing
error-handling patterns.
"""
cmd = privileged_cmd(cmd, user=user)
kwargs.setdefault('capture_output', True)
kwargs.setdefault('text', True)
kwargs.setdefault('timeout', DEFAULT_PRIVILEGED_TIMEOUT)
return subprocess.run(cmd, **kwargs)
def run_command(cmd: Union[List[str], str], *, timeout: int = 60,
capture_stderr: bool = False, **kwargs) -> dict:
"""Run a shell command and return a dict with stdout/stderr/returncode.
This is a convenience wrapper used by services that need simple dict results
rather than a raw ``CompletedProcess`` object.
"""
kwargs.setdefault('capture_output', True)
kwargs.setdefault('text', True)
result = subprocess.run(cmd, timeout=timeout, **kwargs)
return {
'stdout': result.stdout or '',
'stderr': result.stderr or '',
'returncode': result.returncode,
}
def is_command_available(cmd: str) -> bool:
"""Check whether *cmd* is available on the system.
Uses ``shutil.which`` first, then falls back to checking common sbin/local
paths that may not be on the current ``$PATH``. ``/snap/bin`` is included
because snap is the recommended install method for tools like certbot, and
snap's bin dir is frequently missing from a service's ``$PATH``.
"""
if shutil.which(cmd):
return True
for directory in ('/usr/bin', '/usr/sbin', '/usr/local/bin',
'/usr/local/sbin', '/snap/bin'):
if os.path.exists(os.path.join(directory, cmd)):
return True
return False
def sourced_result(lines: list, source: str, source_label: str) -> dict:
"""Standard response shape for multi-source data endpoints.
Every fallback-chain endpoint should return this shape so the frontend
can show a consistent source-aware banner.
"""
return {
'success': True,
'lines': lines,
'count': len(lines),
'source': source,
'source_label': source_label,
}
class PackageManager:
"""Cross-distro package management helpers.
Detects ``apt``, ``dnf``, or ``yum`` once and caches the result.
"""
_detected: Optional[str] = None
_detection_done: bool = False
@classmethod
def detect(cls) -> Optional[str]:
"""Return ``'apt'``, ``'dnf'``, ``'yum'``, or ``None``."""
if cls._detection_done:
return cls._detected
for manager in ('apt', 'dnf', 'yum'):
if shutil.which(manager):
cls._detected = manager
cls._detection_done = True
return cls._detected
cls._detection_done = True
return cls._detected
@classmethod
def is_available(cls) -> bool:
"""Return ``True`` if any supported package manager was found."""
return cls.detect() is not None
@classmethod
def is_installed(cls, package: str) -> bool:
"""Check whether *package* is installed (cross-distro).
Uses ``dpkg -s`` on apt systems and ``rpm -q`` on dnf/yum systems.
Catches ``FileNotFoundError`` so it works on any distro.
"""
manager = cls.detect()
if manager == 'apt':
try:
result = subprocess.run(
['dpkg', '-s', package],
capture_output=True, text=True, timeout=PROBE_TIMEOUT,
)
return (
result.returncode == 0
and 'Status: install ok installed' in result.stdout
)
except FileNotFoundError:
return False
if manager in ('dnf', 'yum'):
try:
result = subprocess.run(
['rpm', '-q', package],
capture_output=True, text=True, timeout=PROBE_TIMEOUT,
)
return result.returncode == 0
except FileNotFoundError:
return False
return False
@classmethod
def install(cls, packages: Union[str, List[str]], timeout: int = 300) -> subprocess.CompletedProcess:
"""Install one or more packages (cross-distro).
Raises ``RuntimeError`` when no supported package manager is found.
"""
manager = cls.detect()
if manager is None:
raise RuntimeError('No supported package manager found (apt/dnf/yum)')
if isinstance(packages, str):
packages = [packages]
cmd = [manager, 'install', '-y'] + packages
return run_privileged(cmd, timeout=timeout)
@classmethod
def reset_cache(cls) -> None:
"""Reset the cached detection (useful in tests)."""
cls._detected = None
cls._detection_done = False
class ServiceControl:
"""Thin wrappers around ``systemctl`` that use :func:`run_privileged`."""
@staticmethod
def start(service: str, **kwargs) -> subprocess.CompletedProcess:
return run_privileged(['systemctl', 'start', service], **kwargs)
@staticmethod
def stop(service: str, **kwargs) -> subprocess.CompletedProcess:
return run_privileged(['systemctl', 'stop', service], **kwargs)
@staticmethod
def restart(service: str, **kwargs) -> subprocess.CompletedProcess:
return run_privileged(['systemctl', 'restart', service], **kwargs)
@staticmethod
def reload(service: str, **kwargs) -> subprocess.CompletedProcess:
return run_privileged(['systemctl', 'reload', service], **kwargs)
@staticmethod
def enable(service: str, **kwargs) -> subprocess.CompletedProcess:
return run_privileged(['systemctl', 'enable', service], **kwargs)
@staticmethod
def disable(service: str, **kwargs) -> subprocess.CompletedProcess:
return run_privileged(['systemctl', 'disable', service], **kwargs)
@staticmethod
def daemon_reload(**kwargs) -> subprocess.CompletedProcess:
return run_privileged(['systemctl', 'daemon-reload'], **kwargs)
@staticmethod
def is_active(service: str) -> bool:
"""Return ``True`` when the service is active. No sudo needed."""
try:
result = subprocess.run(
['systemctl', 'is-active', service],
capture_output=True, text=True, timeout=PROBE_TIMEOUT,
)
return result.stdout.strip() == 'active'
except FileNotFoundError:
return False
@staticmethod
def is_enabled(service: str) -> bool:
"""Return ``True`` when the service is enabled. No sudo needed."""
try:
result = subprocess.run(
['systemctl', 'is-enabled', service],
capture_output=True, text=True, timeout=PROBE_TIMEOUT,
)
return result.stdout.strip() == 'enabled'
except FileNotFoundError:
return False