Files

372 lines
11 KiB
Python
Raw Permalink Normal View History

2026-04-07 02:04:22 +05:30
"""YakPanel - App Store / Software API"""
2026-04-07 04:24:16 +05:30
from __future__ import annotations
2026-04-07 04:20:13 +05:30
import asyncio
import os
import shlex
2026-04-07 04:24:16 +05:30
import shutil
2026-04-07 04:20:13 +05:30
import subprocess
2026-04-07 04:24:16 +05:30
from typing import Literal
2026-04-07 04:20:13 +05:30
2026-04-07 02:04:22 +05:30
from fastapi import APIRouter, Depends, HTTPException
2026-04-07 04:28:40 +05:30
from app.core.utils import environment_with_system_path, exec_shell_sync
2026-04-07 02:04:22 +05:30
from app.api.auth import get_current_user
from app.models.user import User
router = APIRouter(prefix="/soft", tags=["soft"])
2026-04-07 04:28:40 +05:30
PmKind = Literal["apt", "dnf", "yum", "microdnf", "apk", "none"]
2026-04-07 04:24:16 +05:30
# 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",
},
2026-04-07 02:04:22 +05:30
]
2026-04-07 04:28:40 +05:30
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
2026-04-07 04:24:16 +05:30
def _detect_package_manager() -> PmKind:
2026-04-07 04:28:40 +05:30
if _resolve_command("apt-get"):
2026-04-07 04:24:16 +05:30
return "apt"
2026-04-07 04:28:40 +05:30
if _resolve_command("dnf"):
2026-04-07 04:24:16 +05:30
return "dnf"
2026-04-07 04:28:40 +05:30
if _resolve_command("yum"):
2026-04-07 04:24:16 +05:30
return "yum"
2026-04-07 04:28:40 +05:30
if _resolve_command("microdnf"):
return "microdnf"
if _resolve_command("apk"):
2026-04-07 04:24:16 +05:30
return "apk"
return "none"
def _package_name(entry: dict[str, str], pm: PmKind) -> str:
if pm == "apt":
return entry["apt"]
2026-04-07 04:28:40 +05:30
if pm in ("dnf", "yum", "microdnf"):
2026-04-07 04:24:16 +05:30
return entry["rpm"]
if pm == "apk":
return entry.get("apk") or entry["apt"]
return entry["apt"]
2026-04-07 04:20:13 +05:30
def _apt_env() -> dict[str, str]:
2026-04-07 04:28:40 +05:30
env = environment_with_system_path()
2026-04-07 04:20:13 +05:30
env.setdefault("DEBIAN_FRONTEND", "noninteractive")
env.setdefault("APT_LISTCHANGES_FRONTEND", "none")
return env
2026-04-07 04:24:16 +05:30
def _run_shell(script: str, timeout: int, env: dict[str, str] | None = None) -> None:
2026-04-07 04:28:40 +05:30
run_env = environment_with_system_path(env)
2026-04-07 04:20:13 +05:30
result = subprocess.run(
script,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
2026-04-07 04:28:40 +05:30
env=run_env,
2026-04-07 04:20:13 +05:30
)
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])
2026-04-07 04:24:16 +05:30
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)
2026-04-07 02:04:22 +05:30
if out.strip():
parts = out.split()
if len(parts) >= 3:
return True, parts[2]
return False, ""
2026-04-07 04:24:16 +05:30
def _check_installed_rpm(pkg: str) -> tuple[bool, str]:
try:
2026-04-07 04:28:40 +05:30
rpm_bin = _resolve_command("rpm") or "rpm"
2026-04-07 04:24:16 +05:30
result = subprocess.run(
2026-04-07 04:28:40 +05:30
[rpm_bin, "-q", "--queryformat", "%{EVR}", pkg],
2026-04-07 04:24:16 +05:30
capture_output=True,
text=True,
timeout=5,
2026-04-07 04:28:40 +05:30
env=environment_with_system_path(),
2026-04-07 04:24:16 +05:30
)
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:
2026-04-07 04:28:40 +05:30
apk_bin = _resolve_command("apk") or "apk"
2026-04-07 04:24:16 +05:30
r = subprocess.run(
2026-04-07 04:28:40 +05:30
[apk_bin, "info", "-e", pkg],
2026-04-07 04:24:16 +05:30
capture_output=True,
text=True,
timeout=5,
2026-04-07 04:28:40 +05:30
env=environment_with_system_path(),
2026-04-07 04:24:16 +05:30
)
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)
2026-04-07 04:28:40 +05:30
if pm in ("dnf", "yum", "microdnf"):
2026-04-07 04:24:16 +05:30
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":
2026-04-07 04:28:40 +05:30
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()
2026-04-07 04:24:16 +05:30
if pm == "dnf":
2026-04-07 04:28:40 +05:30
exe = shlex.quote(x) if (x := _resolve_command("dnf")) else None
if exe:
return f"{exe} install -y {q}", 600, None
2026-04-07 04:24:16 +05:30
if pm == "yum":
2026-04-07 04:28:40 +05:30
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
2026-04-07 04:24:16 +05:30
if pm == "apk":
2026-04-07 04:28:40 +05:30
exe = shlex.quote(x) if (x := _resolve_command("apk")) else None
if exe:
return f"{exe} update && {exe} add {q}", 600, None
2026-04-07 04:24:16 +05:30
raise HTTPException(
status_code=501,
2026-04-07 04:28:40 +05:30
detail="No supported package manager found (need apt-get, dnf, yum, microdnf, or apk).",
2026-04-07 04:24:16 +05:30
)
def _uninstall_script(pm: PmKind, pkg: str) -> tuple[str, int, dict[str, str] | None]:
q = shlex.quote(pkg)
if pm == "apt":
2026-04-07 04:28:40 +05:30
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.")
2026-04-07 04:24:16 +05:30
if pm == "dnf":
2026-04-07 04:28:40 +05:30
exe = shlex.quote(x) if (x := _resolve_command("dnf")) else None
if exe:
return f"{exe} remove -y {q}", 180, None
2026-04-07 04:24:16 +05:30
if pm == "yum":
2026-04-07 04:28:40 +05:30
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
2026-04-07 04:24:16 +05:30
if pm == "apk":
2026-04-07 04:28:40 +05:30
exe = shlex.quote(x) if (x := _resolve_command("apk")) else None
if exe:
return f"{exe} del {q}", 120, None
2026-04-07 04:24:16 +05:30
raise HTTPException(
status_code=501,
2026-04-07 04:28:40 +05:30
detail="No supported package manager found (need apt-get, dnf, yum, microdnf, or apk).",
2026-04-07 04:24:16 +05:30
)
2026-04-07 02:04:22 +05:30
@router.get("/list")
async def soft_list(current_user: User = Depends(get_current_user)):
2026-04-07 04:24:16 +05:30
"""List software with install status for this OS."""
pm = _detect_package_manager()
manager_label = pm if pm != "none" else "unknown"
result: list[dict] = []
2026-04-07 02:04:22 +05:30
for s in SOFTWARE_LIST:
2026-04-07 04:24:16 +05:30
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}
2026-04-07 02:04:22 +05:30
@router.post("/install/{pkg_id}")
async def soft_install(
pkg_id: str,
current_user: User = Depends(get_current_user),
):
2026-04-07 04:24:16 +05:30
"""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:
2026-04-07 02:04:22 +05:30
raise HTTPException(status_code=404, detail="Package not found")
2026-04-07 04:24:16 +05:30
pkg = _package_name(entry, pm)
script, timeout, env = _install_script(pm, pkg)
await asyncio.to_thread(_run_shell, script, timeout, env)
2026-04-07 02:04:22 +05:30
return {"status": True, "msg": "Installed"}
@router.post("/uninstall/{pkg_id}")
async def soft_uninstall(
pkg_id: str,
current_user: User = Depends(get_current_user),
):
2026-04-07 04:24:16 +05:30
"""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:
2026-04-07 02:04:22 +05:30
raise HTTPException(status_code=404, detail="Package not found")
2026-04-07 04:24:16 +05:30
pkg = _package_name(entry, pm)
script, timeout, env = _uninstall_script(pm, pkg)
await asyncio.to_thread(_run_shell, script, timeout, env)
2026-04-07 02:04:22 +05:30
return {"status": True, "msg": "Uninstalled"}