mirror of
https://github.com/openhop-dev/openhop_repeater.git
synced 2026-09-17 09:54:20 +00:00
433 lines
16 KiB
Python
433 lines
16 KiB
Python
"""
|
|
Service management utilities for openHop Repeater.
|
|
Provides functions for service control operations like restart.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess # nosec B404
|
|
import threading
|
|
import time
|
|
from typing import Dict, Optional, Tuple
|
|
|
|
logger = logging.getLogger("ServiceUtils")
|
|
INIT_SCRIPT = "/etc/init.d/S80openhop-repeater"
|
|
BUILDROOT_METADATA_PATH = "/etc/pymc-image-build-id"
|
|
_CONTAINER_RESTART_DELAY_SECONDS = 1.0
|
|
_SH_BIN = shutil.which("sh") or "sh"
|
|
_SYSTEMCTL_BIN = shutil.which("systemctl") or "systemctl"
|
|
_SUDO_BIN = shutil.which("sudo") or "sudo"
|
|
_SUDO_SYSTEMCTL_BIN = "/usr/bin/systemctl"
|
|
|
|
|
|
def is_buildroot() -> bool:
|
|
if os.path.exists(BUILDROOT_METADATA_PATH):
|
|
return True
|
|
if os.path.exists("/etc/os-release"):
|
|
try:
|
|
with open("/etc/os-release", "r", encoding="utf-8") as handle:
|
|
return any(line.strip() == "ID=buildroot" for line in handle)
|
|
except OSError:
|
|
return False
|
|
return False
|
|
|
|
|
|
def get_buildroot_image_info() -> Dict[str, str]:
|
|
info: Dict[str, str] = {}
|
|
|
|
try:
|
|
with open(BUILDROOT_METADATA_PATH, "r", encoding="utf-8") as handle:
|
|
for line in handle:
|
|
line = line.strip()
|
|
if not line or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
info[key.strip()] = value.strip()
|
|
except OSError:
|
|
return {}
|
|
|
|
return info
|
|
|
|
|
|
def get_buildroot_image_version() -> Optional[str]:
|
|
return get_buildroot_image_info().get("image_version")
|
|
|
|
|
|
def is_container() -> bool:
|
|
"""Detect common Docker/LXC/containerized environments."""
|
|
if os.path.exists("/.dockerenv") or os.environ.get("container"):
|
|
return True
|
|
|
|
try:
|
|
with open("/proc/1/environ", "rb") as handle:
|
|
if b"container=" in handle.read():
|
|
return True
|
|
except (OSError, PermissionError):
|
|
pass
|
|
|
|
try:
|
|
with open("/proc/1/cgroup", "r", encoding="utf-8") as handle:
|
|
cgroup_data = handle.read()
|
|
if any(token in cgroup_data for token in ("docker", "containerd", "kubepods", "lxc")):
|
|
return True
|
|
except OSError:
|
|
pass
|
|
|
|
return os.path.exists("/run/host/container-manager")
|
|
|
|
|
|
def _schedule_container_exit(delay_seconds: float = _CONTAINER_RESTART_DELAY_SECONDS) -> None:
|
|
"""Exit the current process shortly after returning success to the caller."""
|
|
|
|
def _exit_process() -> None:
|
|
time.sleep(delay_seconds)
|
|
logger.warning("Exiting repeater process to trigger container restart")
|
|
os._exit(0)
|
|
|
|
threading.Thread(target=_exit_process, name="container-restart-exit", daemon=True).start()
|
|
|
|
|
|
def get_container_restart_message() -> str:
|
|
"""Return the user-facing restart message for containerized installs."""
|
|
return (
|
|
"Container restart initiated. "
|
|
"If you are running openHop Repeater via Docker or Home Assistant, pull or rebuild "
|
|
"a newer image for packaged image updates to take effect."
|
|
)
|
|
|
|
|
|
def ensure_plugin_manager_service() -> Tuple[bool, str]:
|
|
"""Ensure the packaged plugin-manager systemd unit is installed and running.
|
|
|
|
This is intentionally idempotent. It runs as root when already privileged,
|
|
or via sudo when the app is started under a service account, so a single
|
|
upgrade can heal older installs that pre-date the plugin-manager system
|
|
service without requiring a second update.
|
|
"""
|
|
if is_container() or is_buildroot():
|
|
return True, "Plugin-manager bootstrap skipped in container/buildroot environment"
|
|
|
|
venv_python = "/opt/openhop_repeater/venv/bin/python"
|
|
if not os.path.isfile(venv_python):
|
|
return False, "Plugin-manager bootstrap skipped: venv not present"
|
|
|
|
sudo_cmd: list[str] = []
|
|
if os.geteuid() != 0:
|
|
sudo_bin = shutil.which("sudo")
|
|
if not sudo_bin:
|
|
logger.info(
|
|
"Plugin-manager bootstrap skipped: root privileges required for systemd provisioning"
|
|
)
|
|
return (
|
|
True,
|
|
"Plugin-manager bootstrap skipped: root privileges required for systemd provisioning",
|
|
)
|
|
sudo_cmd = [sudo_bin, "--non-interactive"]
|
|
|
|
install_cmd = [
|
|
"/usr/bin/install",
|
|
"-o",
|
|
"root",
|
|
"-g",
|
|
"root",
|
|
"-m",
|
|
"0644",
|
|
]
|
|
daemon_reload = ["/bin/systemctl", "daemon-reload"]
|
|
enable_cmd = ["/bin/systemctl", "enable", "openhop-plugin-manager"]
|
|
is_active_cmd = ["/bin/systemctl", "is-active", "--quiet", "openhop-plugin-manager"]
|
|
start_cmd = ["/bin/systemctl", "start", "openhop-plugin-manager"]
|
|
restart_cmd = ["/bin/systemctl", "restart", "openhop-plugin-manager"]
|
|
|
|
package_unit = None
|
|
code = """
|
|
from importlib.metadata import distribution
|
|
from pathlib import Path
|
|
import sys
|
|
try:
|
|
dist = distribution('openhop_repeater')
|
|
except Exception:
|
|
sys.exit(1)
|
|
try:
|
|
path = Path(dist.locate_file('repeater/plugins/openhop-plugin-manager.service'))
|
|
except Exception:
|
|
sys.exit(1)
|
|
if path.is_file():
|
|
print(path)
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
"""
|
|
try:
|
|
result = subprocess.run( # nosec B603
|
|
[venv_python, "-I", "-c", code],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=20,
|
|
check=False,
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
package_unit = result.stdout.strip().splitlines()[-1]
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
logger.warning("Plugin-manager bootstrap lookup failed: %s", exc)
|
|
return False, f"Plugin-manager bootstrap lookup failed: {exc}"
|
|
|
|
if not package_unit:
|
|
return (
|
|
False,
|
|
"Plugin-manager bootstrap skipped: packaged service not found in installed package",
|
|
)
|
|
|
|
unit_path = "/etc/systemd/system/openhop-plugin-manager.service"
|
|
changed = False
|
|
try:
|
|
if not os.path.exists(unit_path):
|
|
logger.info("Plugin-manager unit missing; installing packaged service")
|
|
changed = True
|
|
else:
|
|
with open(package_unit, "rb") as src, open(unit_path, "rb") as dst:
|
|
if src.read() != dst.read():
|
|
logger.info("Plugin-manager unit differs from packaged version; updating")
|
|
changed = True
|
|
|
|
if changed:
|
|
subprocess.run( # nosec B603
|
|
sudo_cmd + install_cmd + [package_unit, unit_path],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
subprocess.run( # nosec B603
|
|
sudo_cmd + daemon_reload,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
logger.info("Plugin-manager unit installed or refreshed")
|
|
else:
|
|
logger.info("Plugin-manager unit already matches packaged version")
|
|
|
|
subprocess.run( # nosec B603
|
|
sudo_cmd + enable_cmd,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
active = subprocess.run( # nosec B603
|
|
sudo_cmd + is_active_cmd,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if active.returncode != 0:
|
|
logger.info("Starting plugin-manager service")
|
|
subprocess.run( # nosec B603
|
|
sudo_cmd + start_cmd,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
elif changed:
|
|
logger.info("Restarting plugin-manager service after unit refresh")
|
|
subprocess.run( # nosec B603
|
|
sudo_cmd + restart_cmd,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
return True, "Plugin-manager service is installed and active"
|
|
except Exception as exc:
|
|
logger.warning("Plugin-manager bootstrap failed: %s", exc)
|
|
return False, f"Plugin-manager bootstrap failed: {exc}"
|
|
|
|
|
|
def restart_service() -> Tuple[bool, str]:
|
|
"""
|
|
Restart the openhop-repeater service.
|
|
|
|
On Buildroot/Luckfox, use the shipped init script directly.
|
|
On systemd hosts, submit a delayed transient timer through the exact
|
|
sudo command authorized by manage.sh. The restart runs outside this
|
|
service's cgroup, after the API can acknowledge successful scheduling.
|
|
|
|
Returns:
|
|
Tuple[bool, str]: (success, message)
|
|
"""
|
|
if is_container():
|
|
_schedule_container_exit()
|
|
logger.info("Container environment detected; scheduled process exit for container restart")
|
|
return True, get_container_restart_message()
|
|
|
|
if is_buildroot():
|
|
if not os.path.exists(INIT_SCRIPT):
|
|
logger.error("Buildroot init script not found: %s", INIT_SCRIPT)
|
|
return False, f"init script not found: {INIT_SCRIPT}"
|
|
|
|
try:
|
|
subprocess.Popen(
|
|
[_SH_BIN, "-c", f"sleep 1; exec {INIT_SCRIPT} restart >/dev/null 2>&1"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
stdin=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
) # nosec B603
|
|
logger.info("Service restart scheduled via Buildroot init script")
|
|
return True, "Service restart initiated"
|
|
except Exception as exc:
|
|
logger.error(f"Buildroot restart failed: {exc}")
|
|
return False, f"Restart failed: {exc}"
|
|
|
|
def _try_legacy_restart(reason: str) -> Tuple[bool, str]:
|
|
"""Fallback to the pre-systemd-run direct restart path."""
|
|
legacy_cmd = [systemctl_bin, "restart", "openhop-repeater"]
|
|
if os.geteuid() != 0:
|
|
legacy_cmd = [_SUDO_BIN, "--non-interactive", *legacy_cmd]
|
|
try:
|
|
legacy = subprocess.run(
|
|
legacy_cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
) # nosec B603
|
|
if legacy.returncode == 0:
|
|
logger.info("Service restart via legacy fallback succeeded (%s)", reason)
|
|
return True, "Service restart initiated (legacy fallback)"
|
|
if legacy.returncode < 0:
|
|
# When running inside the service cgroup, a direct restart can
|
|
# terminate this process before subprocess.run returns. Treat
|
|
# termination by signal as likely success.
|
|
logger.warning(
|
|
"Legacy restart interrupted by signal (%s) after %s; treating as likely success",
|
|
legacy.returncode,
|
|
reason,
|
|
)
|
|
return (
|
|
True,
|
|
"Service restart likely initiated (legacy fallback interrupted by signal)",
|
|
)
|
|
legacy_err = legacy.stderr.strip() or f"exit status {legacy.returncode}"
|
|
return False, f"legacy fallback failed: {legacy_err}"
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("Legacy restart timed out after %s", reason)
|
|
return True, "Service restart initiated (legacy fallback timeout - likely restarting)"
|
|
except FileNotFoundError:
|
|
return False, "legacy fallback unavailable: systemctl/sudo not found"
|
|
except Exception as exc:
|
|
return False, f"legacy fallback error: {exc}"
|
|
|
|
# A command launched inside this service shares its cgroup, even with
|
|
# --no-block or start_new_session. PID 1 must own the delayed restart so
|
|
# submission can return before our process group receives SIGTERM.
|
|
# Non-root restarts are constrained by manage.sh sudoers allowlists,
|
|
# which pin /usr/bin/systemctl in the permitted command string.
|
|
systemctl_bin = _SYSTEMCTL_BIN
|
|
if os.geteuid() != 0 and os.path.exists(_SUDO_SYSTEMCTL_BIN):
|
|
systemctl_bin = _SUDO_SYSTEMCTL_BIN
|
|
|
|
command = [
|
|
"/usr/bin/systemd-run",
|
|
"--quiet",
|
|
"--collect",
|
|
"--unit=openhop-repeater-restart",
|
|
"--on-active=2s",
|
|
"--timer-property=AccuracySec=100ms",
|
|
"--timer-property=RemainAfterElapse=no",
|
|
systemctl_bin,
|
|
"restart",
|
|
"openhop-repeater",
|
|
]
|
|
if os.geteuid() != 0:
|
|
command = [_SUDO_BIN, "--non-interactive", *command]
|
|
try:
|
|
result = subprocess.run(command, capture_output=True, text=True, timeout=5) # nosec B603
|
|
if result.returncode == 0:
|
|
logger.info("Service restart scheduled via systemd timer")
|
|
return True, "Service restart initiated (scheduled in 2 seconds)"
|
|
if result.returncode < 0:
|
|
logger.warning("Restart job submission interrupted (%s)", result.returncode)
|
|
return (
|
|
False,
|
|
"Restart unconfirmed: command interrupted; check service status before retrying",
|
|
)
|
|
error_msg = result.stderr.strip() or f"exit status {result.returncode}"
|
|
fallback_ok, fallback_msg = _try_legacy_restart("scheduled restart failure")
|
|
if fallback_ok:
|
|
return True, fallback_msg
|
|
logger.error("Restart scheduling failed: %s", error_msg)
|
|
return False, f"Restart failed: {error_msg}; {fallback_msg}"
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("Restart scheduling timed out; acceptance is unconfirmed")
|
|
return (
|
|
False,
|
|
"Restart unconfirmed: submission timeout; check service status before retrying",
|
|
)
|
|
except FileNotFoundError:
|
|
fallback_ok, fallback_msg = _try_legacy_restart("missing systemd-run scheduler")
|
|
if fallback_ok:
|
|
return True, fallback_msg
|
|
return False, (
|
|
"Restart scheduler unavailable; install systemd-run and sudo via manage.sh; "
|
|
f"{fallback_msg}"
|
|
)
|
|
except Exception as exc:
|
|
fallback_ok, fallback_msg = _try_legacy_restart("restart scheduler exception")
|
|
if fallback_ok:
|
|
return True, fallback_msg
|
|
logger.error("Error scheduling restart: %s", exc)
|
|
return False, f"Restart command failed: {exc}; {fallback_msg}"
|
|
|
|
|
|
def _is_cherrypy_engine_running() -> Optional[bool]:
|
|
"""Return CherryPy engine running state when available."""
|
|
try:
|
|
import cherrypy
|
|
from cherrypy.process import wspbus
|
|
|
|
state = cherrypy.engine.state
|
|
return state in (wspbus.states.STARTING, wspbus.states.STARTED)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def stop_http_server(daemon_instance) -> Tuple[bool, str]:
|
|
"""Stop the in-process HTTP stats server."""
|
|
if not daemon_instance:
|
|
return False, "Daemon instance not available"
|
|
|
|
http_server = getattr(daemon_instance, "http_server", None)
|
|
if not http_server:
|
|
return False, "HTTP server not initialized"
|
|
|
|
running = _is_cherrypy_engine_running()
|
|
if running is False:
|
|
return True, "HTTP server already stopped"
|
|
|
|
try:
|
|
http_server.stop()
|
|
return True, "HTTP server stopped"
|
|
except Exception as exc:
|
|
logger.error(f"Failed to stop HTTP server: {exc}", exc_info=True)
|
|
return False, f"Failed to stop HTTP server: {exc}"
|
|
|
|
|
|
def start_http_server(daemon_instance) -> Tuple[bool, str]:
|
|
"""Start the in-process HTTP stats server."""
|
|
if not daemon_instance:
|
|
return False, "Daemon instance not available"
|
|
|
|
http_server = getattr(daemon_instance, "http_server", None)
|
|
if not http_server:
|
|
return False, "HTTP server not initialized"
|
|
|
|
running = _is_cherrypy_engine_running()
|
|
if running is True:
|
|
return True, "HTTP server already running"
|
|
|
|
try:
|
|
http_server.start()
|
|
return True, "HTTP server started"
|
|
except Exception as exc:
|
|
logger.error(f"Failed to start HTTP server: {exc}", exc_info=True)
|
|
return False, f"Failed to start HTTP server: {exc}"
|