new changes
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
"""YakPanel - App Store / Software API"""
|
"""YakPanel - App Store / Software API"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
@@ -12,25 +16,147 @@ from app.models.user import User
|
|||||||
|
|
||||||
router = APIRouter(prefix="/soft", tags=["soft"])
|
router = APIRouter(prefix="/soft", tags=["soft"])
|
||||||
|
|
||||||
# Curated list of common server software (Debian/Ubuntu package names)
|
PmKind = Literal["apt", "dnf", "yum", "apk", "none"]
|
||||||
SOFTWARE_LIST = [
|
|
||||||
{"id": "nginx", "name": "Nginx", "desc": "Web server", "pkg": "nginx"},
|
# Per distro: apt = Debian/Ubuntu, rpm = RHEL/Fedora/Alma/Rocky, apk = Alpine (best-effort)
|
||||||
{"id": "mysql-server", "name": "MySQL Server", "desc": "Database server", "pkg": "mysql-server"},
|
SOFTWARE_LIST: list[dict[str, str]] = [
|
||||||
{"id": "mariadb-server", "name": "MariaDB", "desc": "Database server", "pkg": "mariadb-server"},
|
{
|
||||||
{"id": "php", "name": "PHP", "desc": "PHP runtime", "pkg": "php"},
|
"id": "nginx",
|
||||||
{"id": "php-fpm", "name": "PHP-FPM", "desc": "PHP FastCGI", "pkg": "php-fpm"},
|
"name": "Nginx",
|
||||||
{"id": "redis-server", "name": "Redis", "desc": "In-memory cache", "pkg": "redis-server"},
|
"desc": "Web server",
|
||||||
{"id": "postgresql", "name": "PostgreSQL", "desc": "Database server", "pkg": "postgresql"},
|
"apt": "nginx",
|
||||||
{"id": "mongodb", "name": "MongoDB", "desc": "NoSQL database", "pkg": "mongodb"},
|
"rpm": "nginx",
|
||||||
{"id": "certbot", "name": "Certbot", "desc": "Let's Encrypt SSL", "pkg": "certbot"},
|
"apk": "nginx",
|
||||||
{"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": "mysql-server",
|
||||||
{"id": "git", "name": "Git", "desc": "Version control", "pkg": "git"},
|
"name": "MySQL Server",
|
||||||
{"id": "python3", "name": "Python 3", "desc": "Python runtime", "pkg": "python3"},
|
"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]:
|
def _apt_env() -> dict[str, str]:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.setdefault("DEBIAN_FRONTEND", "noninteractive")
|
env.setdefault("DEBIAN_FRONTEND", "noninteractive")
|
||||||
@@ -38,15 +164,14 @@ def _apt_env() -> dict[str, str]:
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
def _run_apt_shell(script: str, timeout: int) -> None:
|
def _run_shell(script: str, timeout: int, env: dict[str, str] | None = None) -> None:
|
||||||
"""Run apt/dpkg shell snippet; raise HTTPException if command fails."""
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
script,
|
script,
|
||||||
shell=True,
|
shell=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
env=_apt_env(),
|
env=env if env is not None else os.environ.copy(),
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
return
|
return
|
||||||
@@ -56,29 +181,113 @@ def _run_apt_shell(script: str, timeout: int) -> None:
|
|||||||
raise HTTPException(status_code=500, detail=msg[:4000])
|
raise HTTPException(status_code=500, detail=msg[:4000])
|
||||||
|
|
||||||
|
|
||||||
def _check_installed(pkg: str) -> tuple[bool, str]:
|
def _check_installed_apt(pkg: str) -> tuple[bool, str]:
|
||||||
"""Check if package is installed. Returns (installed, version_or_error)."""
|
out, _ = exec_shell_sync(f"dpkg -l {shlex.quote(pkg)} 2>/dev/null | grep ^ii", timeout=5)
|
||||||
out, err = exec_shell_sync(f"dpkg -l {pkg} 2>/dev/null | grep ^ii", timeout=5)
|
|
||||||
if out.strip():
|
if out.strip():
|
||||||
# Parse version from dpkg output: ii pkg version ...
|
|
||||||
parts = out.split()
|
parts = out.split()
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
return True, parts[2]
|
return True, parts[2]
|
||||||
return False, ""
|
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")
|
@router.get("/list")
|
||||||
async def soft_list(current_user: User = Depends(get_current_user)):
|
async def soft_list(current_user: User = Depends(get_current_user)):
|
||||||
"""List software with install status"""
|
"""List software with install status for this OS."""
|
||||||
result = []
|
pm = _detect_package_manager()
|
||||||
|
manager_label = pm if pm != "none" else "unknown"
|
||||||
|
result: list[dict] = []
|
||||||
for s in SOFTWARE_LIST:
|
for s in SOFTWARE_LIST:
|
||||||
installed, version = _check_installed(s["pkg"])
|
pkg = _package_name(s, pm) if pm != "none" else s["apt"]
|
||||||
result.append({
|
installed, version = _check_installed(pm, pkg) if pm != "none" else (False, "")
|
||||||
**s,
|
result.append(
|
||||||
"installed": installed,
|
{
|
||||||
"version": version if installed else "",
|
"id": s["id"],
|
||||||
})
|
"name": s["name"],
|
||||||
return {"software": result}
|
"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}")
|
@router.post("/install/{pkg_id}")
|
||||||
@@ -86,12 +295,14 @@ async def soft_install(
|
|||||||
pkg_id: str,
|
pkg_id: str,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Install package via apt (requires root)"""
|
"""Install package via system package manager (root privileges required)."""
|
||||||
pkg = next((s["pkg"] for s in SOFTWARE_LIST if s["id"] == pkg_id), None)
|
pm = _detect_package_manager()
|
||||||
if not pkg:
|
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")
|
raise HTTPException(status_code=404, detail="Package not found")
|
||||||
quoted = shlex.quote(pkg)
|
pkg = _package_name(entry, pm)
|
||||||
_run_apt_shell(f"apt-get update -qq && apt-get install -y {quoted}", timeout=600)
|
script, timeout, env = _install_script(pm, pkg)
|
||||||
|
await asyncio.to_thread(_run_shell, script, timeout, env)
|
||||||
return {"status": True, "msg": "Installed"}
|
return {"status": True, "msg": "Installed"}
|
||||||
|
|
||||||
|
|
||||||
@@ -100,10 +311,12 @@ async def soft_uninstall(
|
|||||||
pkg_id: str,
|
pkg_id: str,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Uninstall package via apt"""
|
"""Uninstall package via system package manager."""
|
||||||
pkg = next((s["pkg"] for s in SOFTWARE_LIST if s["id"] == pkg_id), None)
|
pm = _detect_package_manager()
|
||||||
if not pkg:
|
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")
|
raise HTTPException(status_code=404, detail="Package not found")
|
||||||
quoted = shlex.quote(pkg)
|
pkg = _package_name(entry, pm)
|
||||||
await asyncio.to_thread(_run_apt_shell, f"apt-get remove -y {quoted}", 180)
|
script, timeout, env = _uninstall_script(pm, pkg)
|
||||||
|
await asyncio.to_thread(_run_shell, script, timeout, env)
|
||||||
return {"status": True, "msg": "Uninstalled"}
|
return {"status": True, "msg": "Uninstalled"}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface Software {
|
|||||||
pkg: string
|
pkg: string
|
||||||
installed: boolean
|
installed: boolean
|
||||||
version: string
|
version: string
|
||||||
|
package_manager?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SoftPage() {
|
export function SoftPage() {
|
||||||
@@ -16,11 +17,15 @@ export function SoftPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [actionId, setActionId] = useState<string | null>(null)
|
const [actionId, setActionId] = useState<string | null>(null)
|
||||||
|
const [detectedPm, setDetectedPm] = useState<string>('')
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
apiRequest<{ software: Software[] }>('/soft/list')
|
apiRequest<{ software: Software[]; package_manager?: string }>('/soft/list')
|
||||||
.then((data) => setSoftware(data.software || []))
|
.then((data) => {
|
||||||
|
setSoftware(data.software || [])
|
||||||
|
setDetectedPm(data.package_manager || '')
|
||||||
|
})
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
@@ -69,7 +74,8 @@ export function SoftPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
|
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
|
||||||
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).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user