Files
yakpanel-core/YakPanel-server/backend/app/api/soft.py
2026-04-07 04:24:16 +05:30

323 lines
8.8 KiB
Python

"""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
from app.core.utils import 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"]
# 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")
env.setdefault("APT_LISTCHANGES_FRONTEND", "none")
return env
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=env if env is not None else os.environ.copy(),
)
if result.returncode == 0:
return
out = (result.stdout or "").strip()
err = (result.stderr or "").strip()
msg = err or out or f"Command failed (exit {result.returncode})"
raise HTTPException(status_code=500, detail=msg[:4000])
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():
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 for this OS."""
pm = _detect_package_manager()
manager_label = pm if pm != "none" else "unknown"
result: list[dict] = []
for s in SOFTWARE_LIST:
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}")
async def soft_install(
pkg_id: str,
current_user: User = Depends(get_current_user),
):
"""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")
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"}
@router.post("/uninstall/{pkg_id}")
async def soft_uninstall(
pkg_id: str,
current_user: User = Depends(get_current_user),
):
"""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")
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"}