diff --git a/YakPanel-server/backend/app/api/__pycache__/soft.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/soft.cpython-314.pyc index 876c2fe9..d6ba5970 100644 Binary files a/YakPanel-server/backend/app/api/__pycache__/soft.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/soft.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/api/soft.py b/YakPanel-server/backend/app/api/soft.py index b68fd989..7f2b3e45 100644 --- a/YakPanel-server/backend/app/api/soft.py +++ b/YakPanel-server/backend/app/api/soft.py @@ -10,13 +10,13 @@ from typing import Literal from fastapi import APIRouter, Depends, HTTPException -from app.core.utils import exec_shell_sync +from app.core.utils import environment_with_system_path, exec_shell_sync from app.api.auth import get_current_user from app.models.user import User router = APIRouter(prefix="/soft", tags=["soft"]) -PmKind = Literal["apt", "dnf", "yum", "apk", "none"] +PmKind = Literal["apt", "dnf", "yum", "microdnf", "apk", "none"] # Per distro: apt = Debian/Ubuntu, rpm = RHEL/Fedora/Alma/Rocky, apk = Alpine (best-effort) SOFTWARE_LIST: list[dict[str, str]] = [ @@ -135,14 +135,30 @@ SOFTWARE_LIST: list[dict[str, str]] = [ ] +def _resolve_command(name: str) -> str | None: + """Locate an executable; systemd often provides a venv-only PATH, so scan standard dirs too.""" + std = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + merged = f"{os.environ.get('PATH', '').strip()}:{std}".strip(":") + found = shutil.which(name, path=merged) + if found: + return found + for d in ("/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/local/bin", "/usr/local/sbin"): + cand = os.path.join(d, name) + if os.path.isfile(cand) and os.access(cand, os.X_OK): + return cand + return None + + def _detect_package_manager() -> PmKind: - if shutil.which("apt-get"): + if _resolve_command("apt-get"): return "apt" - if shutil.which("dnf"): + if _resolve_command("dnf"): return "dnf" - if shutil.which("yum"): + if _resolve_command("yum"): return "yum" - if shutil.which("apk"): + if _resolve_command("microdnf"): + return "microdnf" + if _resolve_command("apk"): return "apk" return "none" @@ -150,7 +166,7 @@ def _detect_package_manager() -> PmKind: def _package_name(entry: dict[str, str], pm: PmKind) -> str: if pm == "apt": return entry["apt"] - if pm in ("dnf", "yum"): + if pm in ("dnf", "yum", "microdnf"): return entry["rpm"] if pm == "apk": return entry.get("apk") or entry["apt"] @@ -158,20 +174,21 @@ def _package_name(entry: dict[str, str], pm: PmKind) -> str: def _apt_env() -> dict[str, str]: - env = os.environ.copy() + env = environment_with_system_path() env.setdefault("DEBIAN_FRONTEND", "noninteractive") env.setdefault("APT_LISTCHANGES_FRONTEND", "none") return env def _run_shell(script: str, timeout: int, env: dict[str, str] | None = None) -> None: + run_env = environment_with_system_path(env) result = subprocess.run( script, shell=True, capture_output=True, text=True, timeout=timeout, - env=env if env is not None else os.environ.copy(), + env=run_env, ) if result.returncode == 0: return @@ -192,11 +209,13 @@ def _check_installed_apt(pkg: str) -> tuple[bool, str]: def _check_installed_rpm(pkg: str) -> tuple[bool, str]: try: + rpm_bin = _resolve_command("rpm") or "rpm" result = subprocess.run( - ["rpm", "-q", "--queryformat", "%{EVR}", pkg], + [rpm_bin, "-q", "--queryformat", "%{EVR}", pkg], capture_output=True, text=True, timeout=5, + env=environment_with_system_path(), ) if result.returncode != 0: return False, "" @@ -208,11 +227,13 @@ def _check_installed_rpm(pkg: str) -> tuple[bool, str]: def _check_installed_apk(pkg: str) -> tuple[bool, str]: try: + apk_bin = _resolve_command("apk") or "apk" r = subprocess.run( - ["apk", "info", "-e", pkg], + [apk_bin, "info", "-e", pkg], capture_output=True, text=True, timeout=5, + env=environment_with_system_path(), ) if r.returncode != 0: return False, "" @@ -228,7 +249,7 @@ def _check_installed_apk(pkg: str) -> tuple[bool, str]: def _check_installed(pm: PmKind, pkg: str) -> tuple[bool, str]: if pm == "apt": return _check_installed_apt(pkg) - if pm in ("dnf", "yum"): + if pm in ("dnf", "yum", "microdnf"): return _check_installed_rpm(pkg) if pm == "apk": return _check_installed_apk(pkg) @@ -238,32 +259,60 @@ def _check_installed(pm: PmKind, pkg: str) -> tuple[bool, str]: def _install_script(pm: PmKind, pkg: str) -> tuple[str, int, dict[str, str] | None]: q = shlex.quote(pkg) if pm == "apt": - return f"apt-get update -qq && apt-get install -y {q}", 600, _apt_env() + ag = _resolve_command("apt-get") + if not ag: + raise HTTPException(status_code=501, detail="apt-get not found on this system.") + exe = shlex.quote(ag) + return f"{exe} update -qq && {exe} install -y {q}", 600, _apt_env() if pm == "dnf": - return f"dnf install -y {q}", 600, None + exe = shlex.quote(x) if (x := _resolve_command("dnf")) else None + if exe: + return f"{exe} install -y {q}", 600, None if pm == "yum": - return f"yum install -y {q}", 600, None + exe = shlex.quote(x) if (x := _resolve_command("yum")) else None + if exe: + return f"{exe} install -y {q}", 600, None + if pm == "microdnf": + exe = shlex.quote(x) if (x := _resolve_command("microdnf")) else None + if exe: + return f"{exe} install -y {q}", 600, None if pm == "apk": - return f"apk update && apk add {q}", 600, None + exe = shlex.quote(x) if (x := _resolve_command("apk")) else None + if exe: + return f"{exe} update && {exe} add {q}", 600, None raise HTTPException( status_code=501, - detail="No supported package manager found (need apt-get, dnf, yum, or apk).", + detail="No supported package manager found (need apt-get, dnf, yum, microdnf, or apk).", ) def _uninstall_script(pm: PmKind, pkg: str) -> tuple[str, int, dict[str, str] | None]: q = shlex.quote(pkg) if pm == "apt": - return f"apt-get remove -y {q}", 180, _apt_env() + ag = _resolve_command("apt-get") + if ag: + exe = shlex.quote(ag) + return f"{exe} remove -y {q}", 180, _apt_env() + raise HTTPException(status_code=501, detail="apt-get not found on this system.") if pm == "dnf": - return f"dnf remove -y {q}", 180, None + exe = shlex.quote(x) if (x := _resolve_command("dnf")) else None + if exe: + return f"{exe} remove -y {q}", 180, None if pm == "yum": - return f"yum remove -y {q}", 180, None + exe = shlex.quote(x) if (x := _resolve_command("yum")) else None + if exe: + return f"{exe} remove -y {q}", 180, None + if pm == "microdnf": + exe = shlex.quote(x) if (x := _resolve_command("microdnf")) else None + if exe: + return f"{exe} remove -y {q}", 180, None if pm == "apk": - return f"apk del {q}", 120, None + exe = shlex.quote(x) if (x := _resolve_command("apk")) else None + if exe: + return f"{exe} del {q}", 120, None raise HTTPException( status_code=501, - detail="No supported package manager found (need apt-get, dnf, yum, or apk).", + detail="No supported package manager found (need apt-get, dnf, yum, microdnf, or apk).", ) diff --git a/YakPanel-server/backend/app/core/__pycache__/utils.cpython-314.pyc b/YakPanel-server/backend/app/core/__pycache__/utils.cpython-314.pyc index 6d3b851b..ee5fcc94 100644 Binary files a/YakPanel-server/backend/app/core/__pycache__/utils.cpython-314.pyc and b/YakPanel-server/backend/app/core/__pycache__/utils.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/core/utils.py b/YakPanel-server/backend/app/core/utils.py index ab006636..dc92b065 100644 --- a/YakPanel-server/backend/app/core/utils.py +++ b/YakPanel-server/backend/app/core/utils.py @@ -9,6 +9,24 @@ from typing import Tuple, Optional regex_safe_path = re.compile(r"^[\w\s./\-]*$") +# systemd often sets PATH to venv-only; subprocess shells then miss /usr/bin (dnf, apt-get, …). +_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + + +def ensure_system_path(env: dict[str, str]) -> None: + """Append standard locations to PATH if /usr/bin is missing.""" + cur = (env.get("PATH") or "").strip() + if "/usr/bin" in cur: + return + env["PATH"] = f"{cur}:{_SYSTEM_PATH}" if cur else _SYSTEM_PATH + + +def environment_with_system_path(base: Optional[dict[str, str]] = None) -> dict[str, str]: + """Copy of process env (or base) with PATH guaranteed to include system bin dirs.""" + env = dict(base) if base is not None else os.environ.copy() + ensure_system_path(env) + return env + def md5(strings: str | bytes) -> str: """Generate MD5 hash""" @@ -78,6 +96,7 @@ async def exec_shell( stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, + env=environment_with_system_path(), ) try: stdout, stderr = await asyncio.wait_for( @@ -102,6 +121,7 @@ def exec_shell_sync(cmd: str, timeout: Optional[float] = None, cwd: Optional[str capture_output=True, timeout=timeout or 300, cwd=cwd, + env=environment_with_system_path(), ) out = result.stdout.decode("utf-8", errors="replace") if result.stdout else "" err = result.stderr.decode("utf-8", errors="replace") if result.stderr else "" diff --git a/YakPanel-server/frontend/src/pages/SoftPage.tsx b/YakPanel-server/frontend/src/pages/SoftPage.tsx index 714505b1..cc616b43 100644 --- a/YakPanel-server/frontend/src/pages/SoftPage.tsx +++ b/YakPanel-server/frontend/src/pages/SoftPage.tsx @@ -75,7 +75,7 @@ export function SoftPage() {
Installs use your server package manager ({detectedPm || '…loading…'}). Panel must run as root - (or equivalent). Supported: Debian/Ubuntu (apt), RHEL/Fedora/Alma/Rocky (dnf/yum), Alpine (apk). + (or equivalent). Supported: apt, dnf/yum/microdnf, apk.
diff --git a/YakPanel-server/install.sh b/YakPanel-server/install.sh index 80e2daad..759e1a0b 100644 --- a/YakPanel-server/install.sh +++ b/YakPanel-server/install.sh @@ -288,7 +288,8 @@ $REDIS_WANTS Type=simple User=root WorkingDirectory=$INSTALL_PATH/backend -Environment="PATH=$INSTALL_PATH/backend/venv/bin:\$PATH" +# Include system paths: systemd does not expand \$PATH reliably; venv-only PATH breaks dnf/apt from the API. +Environment="PATH=$INSTALL_PATH/backend/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" Environment=PYTHONUNBUFFERED=1 ExecStart=$INSTALL_PATH/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port $BACKEND_PORT Restart=always