diff --git a/YakPanel-server/backend/app/api/soft.py b/YakPanel-server/backend/app/api/soft.py index b9248438..b68fd989 100644 --- a/YakPanel-server/backend/app/api/soft.py +++ b/YakPanel-server/backend/app/api/soft.py @@ -1,8 +1,12 @@ """YakPanel - App Store / Software API""" +from __future__ import annotations + import asyncio import os import shlex +import shutil import subprocess +from typing import Literal from fastapi import APIRouter, Depends, HTTPException @@ -12,25 +16,147 @@ from app.models.user import User router = APIRouter(prefix="/soft", tags=["soft"]) -# Curated list of common server software (Debian/Ubuntu package names) -SOFTWARE_LIST = [ - {"id": "nginx", "name": "Nginx", "desc": "Web server", "pkg": "nginx"}, - {"id": "mysql-server", "name": "MySQL Server", "desc": "Database server", "pkg": "mysql-server"}, - {"id": "mariadb-server", "name": "MariaDB", "desc": "Database server", "pkg": "mariadb-server"}, - {"id": "php", "name": "PHP", "desc": "PHP runtime", "pkg": "php"}, - {"id": "php-fpm", "name": "PHP-FPM", "desc": "PHP FastCGI", "pkg": "php-fpm"}, - {"id": "redis-server", "name": "Redis", "desc": "In-memory cache", "pkg": "redis-server"}, - {"id": "postgresql", "name": "PostgreSQL", "desc": "Database server", "pkg": "postgresql"}, - {"id": "mongodb", "name": "MongoDB", "desc": "NoSQL database", "pkg": "mongodb"}, - {"id": "certbot", "name": "Certbot", "desc": "Let's Encrypt SSL", "pkg": "certbot"}, - {"id": "docker", "name": "Docker", "desc": "Container runtime", "pkg": "docker.io"}, - {"id": "nodejs", "name": "Node.js", "desc": "JavaScript runtime", "pkg": "nodejs"}, - {"id": "npm", "name": "npm", "desc": "Node package manager", "pkg": "npm"}, - {"id": "git", "name": "Git", "desc": "Version control", "pkg": "git"}, - {"id": "python3", "name": "Python 3", "desc": "Python runtime", "pkg": "python3"}, +PmKind = Literal["apt", "dnf", "yum", "apk", "none"] + +# Per distro: apt = Debian/Ubuntu, rpm = RHEL/Fedora/Alma/Rocky, apk = Alpine (best-effort) +SOFTWARE_LIST: list[dict[str, str]] = [ + { + "id": "nginx", + "name": "Nginx", + "desc": "Web server", + "apt": "nginx", + "rpm": "nginx", + "apk": "nginx", + }, + { + "id": "mysql-server", + "name": "MySQL Server", + "desc": "Database server", + "apt": "mysql-server", + "rpm": "mysql-server", + "apk": "mysql", + }, + { + "id": "mariadb-server", + "name": "MariaDB", + "desc": "Database server", + "apt": "mariadb-server", + "rpm": "mariadb-server", + "apk": "mariadb", + }, + { + "id": "php", + "name": "PHP", + "desc": "PHP runtime", + "apt": "php", + "rpm": "php", + "apk": "php", + }, + { + "id": "php-fpm", + "name": "PHP-FPM", + "desc": "PHP FastCGI", + "apt": "php-fpm", + "rpm": "php-fpm", + "apk": "php-fpm", + }, + { + "id": "redis-server", + "name": "Redis", + "desc": "In-memory cache", + "apt": "redis-server", + "rpm": "redis", + "apk": "redis", + }, + { + "id": "postgresql", + "name": "PostgreSQL", + "desc": "Database server", + "apt": "postgresql", + "rpm": "postgresql-server", + "apk": "postgresql", + }, + { + "id": "mongodb", + "name": "MongoDB", + "desc": "NoSQL database", + "apt": "mongodb", + "rpm": "mongodb-server", + "apk": "mongodb", + }, + { + "id": "certbot", + "name": "Certbot", + "desc": "Let's Encrypt SSL", + "apt": "certbot", + "rpm": "certbot", + "apk": "certbot", + }, + { + "id": "docker", + "name": "Docker", + "desc": "Container runtime", + "apt": "docker.io", + "rpm": "docker", + "apk": "docker", + }, + { + "id": "nodejs", + "name": "Node.js", + "desc": "JavaScript runtime", + "apt": "nodejs", + "rpm": "nodejs", + "apk": "nodejs", + }, + { + "id": "npm", + "name": "npm", + "desc": "Node package manager", + "apt": "npm", + "rpm": "npm", + "apk": "npm", + }, + { + "id": "git", + "name": "Git", + "desc": "Version control", + "apt": "git", + "rpm": "git", + "apk": "git", + }, + { + "id": "python3", + "name": "Python 3", + "desc": "Python runtime", + "apt": "python3", + "rpm": "python3", + "apk": "python3", + }, ] +def _detect_package_manager() -> PmKind: + if shutil.which("apt-get"): + return "apt" + if shutil.which("dnf"): + return "dnf" + if shutil.which("yum"): + return "yum" + if shutil.which("apk"): + return "apk" + return "none" + + +def _package_name(entry: dict[str, str], pm: PmKind) -> str: + if pm == "apt": + return entry["apt"] + if pm in ("dnf", "yum"): + return entry["rpm"] + if pm == "apk": + return entry.get("apk") or entry["apt"] + return entry["apt"] + + def _apt_env() -> dict[str, str]: env = os.environ.copy() env.setdefault("DEBIAN_FRONTEND", "noninteractive") @@ -38,15 +164,14 @@ def _apt_env() -> dict[str, str]: return env -def _run_apt_shell(script: str, timeout: int) -> None: - """Run apt/dpkg shell snippet; raise HTTPException if command fails.""" +def _run_shell(script: str, timeout: int, env: dict[str, str] | None = None) -> None: result = subprocess.run( script, shell=True, capture_output=True, text=True, timeout=timeout, - env=_apt_env(), + env=env if env is not None else os.environ.copy(), ) if result.returncode == 0: return @@ -56,29 +181,113 @@ def _run_apt_shell(script: str, timeout: int) -> None: raise HTTPException(status_code=500, detail=msg[:4000]) -def _check_installed(pkg: str) -> tuple[bool, str]: - """Check if package is installed. Returns (installed, version_or_error).""" - out, err = exec_shell_sync(f"dpkg -l {pkg} 2>/dev/null | grep ^ii", timeout=5) +def _check_installed_apt(pkg: str) -> tuple[bool, str]: + out, _ = exec_shell_sync(f"dpkg -l {shlex.quote(pkg)} 2>/dev/null | grep ^ii", timeout=5) if out.strip(): - # Parse version from dpkg output: ii pkg version ... parts = out.split() if len(parts) >= 3: return True, parts[2] return False, "" +def _check_installed_rpm(pkg: str) -> tuple[bool, str]: + try: + result = subprocess.run( + ["rpm", "-q", "--queryformat", "%{EVR}", pkg], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + return False, "" + ver = (result.stdout or "").strip() + return (True, ver) if ver else (True, "") + except (FileNotFoundError, subprocess.TimeoutExpired): + return False, "" + + +def _check_installed_apk(pkg: str) -> tuple[bool, str]: + try: + r = subprocess.run( + ["apk", "info", "-e", pkg], + capture_output=True, + text=True, + timeout=5, + ) + if r.returncode != 0: + return False, "" + out = (r.stdout or "").strip() + if not out: + return True, "" + first = out.split("\n")[0].strip() + return True, first + except (FileNotFoundError, subprocess.TimeoutExpired): + return False, "" + + +def _check_installed(pm: PmKind, pkg: str) -> tuple[bool, str]: + if pm == "apt": + return _check_installed_apt(pkg) + if pm in ("dnf", "yum"): + return _check_installed_rpm(pkg) + if pm == "apk": + return _check_installed_apk(pkg) + return False, "" + + +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() + if pm == "dnf": + return f"dnf install -y {q}", 600, None + if pm == "yum": + return f"yum install -y {q}", 600, None + if pm == "apk": + return f"apk update && apk add {q}", 600, None + raise HTTPException( + status_code=501, + detail="No supported package manager found (need apt-get, dnf, yum, 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() + if pm == "dnf": + return f"dnf remove -y {q}", 180, None + if pm == "yum": + return f"yum remove -y {q}", 180, None + if pm == "apk": + return f"apk del {q}", 120, None + raise HTTPException( + status_code=501, + detail="No supported package manager found (need apt-get, dnf, yum, or apk).", + ) + + @router.get("/list") async def soft_list(current_user: User = Depends(get_current_user)): - """List software with install status""" - result = [] + """List software with install status for this OS.""" + pm = _detect_package_manager() + manager_label = pm if pm != "none" else "unknown" + result: list[dict] = [] for s in SOFTWARE_LIST: - installed, version = _check_installed(s["pkg"]) - result.append({ - **s, - "installed": installed, - "version": version if installed else "", - }) - return {"software": result} + pkg = _package_name(s, pm) if pm != "none" else s["apt"] + installed, version = _check_installed(pm, pkg) if pm != "none" else (False, "") + result.append( + { + "id": s["id"], + "name": s["name"], + "desc": s["desc"], + "pkg": pkg, + "installed": installed, + "version": version if installed else "", + "package_manager": manager_label, + } + ) + return {"software": result, "package_manager": manager_label} @router.post("/install/{pkg_id}") @@ -86,12 +295,14 @@ async def soft_install( pkg_id: str, current_user: User = Depends(get_current_user), ): - """Install package via apt (requires root)""" - pkg = next((s["pkg"] for s in SOFTWARE_LIST if s["id"] == pkg_id), None) - if not pkg: + """Install package via system package manager (root privileges required).""" + pm = _detect_package_manager() + entry = next((s for s in SOFTWARE_LIST if s["id"] == pkg_id), None) + if not entry: raise HTTPException(status_code=404, detail="Package not found") - quoted = shlex.quote(pkg) - _run_apt_shell(f"apt-get update -qq && apt-get install -y {quoted}", timeout=600) + pkg = _package_name(entry, pm) + script, timeout, env = _install_script(pm, pkg) + await asyncio.to_thread(_run_shell, script, timeout, env) return {"status": True, "msg": "Installed"} @@ -100,10 +311,12 @@ async def soft_uninstall( pkg_id: str, current_user: User = Depends(get_current_user), ): - """Uninstall package via apt""" - pkg = next((s["pkg"] for s in SOFTWARE_LIST if s["id"] == pkg_id), None) - if not pkg: + """Uninstall package via system package manager.""" + pm = _detect_package_manager() + entry = next((s for s in SOFTWARE_LIST if s["id"] == pkg_id), None) + if not entry: raise HTTPException(status_code=404, detail="Package not found") - quoted = shlex.quote(pkg) - await asyncio.to_thread(_run_apt_shell, f"apt-get remove -y {quoted}", 180) + pkg = _package_name(entry, pm) + script, timeout, env = _uninstall_script(pm, pkg) + await asyncio.to_thread(_run_shell, script, timeout, env) return {"status": True, "msg": "Uninstalled"} diff --git a/YakPanel-server/frontend/src/pages/SoftPage.tsx b/YakPanel-server/frontend/src/pages/SoftPage.tsx index 642f11bb..714505b1 100644 --- a/YakPanel-server/frontend/src/pages/SoftPage.tsx +++ b/YakPanel-server/frontend/src/pages/SoftPage.tsx @@ -9,6 +9,7 @@ interface Software { pkg: string installed: boolean version: string + package_manager?: string } export function SoftPage() { @@ -16,11 +17,15 @@ export function SoftPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [actionId, setActionId] = useState(null) + const [detectedPm, setDetectedPm] = useState('') const load = () => { setLoading(true) - apiRequest<{ software: Software[] }>('/soft/list') - .then((data) => setSoftware(data.software || [])) + apiRequest<{ software: Software[]; package_manager?: string }>('/soft/list') + .then((data) => { + setSoftware(data.software || []) + setDetectedPm(data.package_manager || '') + }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)) } @@ -69,7 +74,8 @@ export function SoftPage() { )}
- Install/uninstall via apt. Panel must run with sufficient privileges. Target: Debian/Ubuntu. + 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).