Initial YakPanel commit
This commit is contained in:
1
YakPanel-server/backend/app/__init__.py
Normal file
1
YakPanel-server/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# YakPanel - Backend Application
|
||||
BIN
YakPanel-server/backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
YakPanel-server/backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
1
YakPanel-server/backend/app/api/__init__.py
Normal file
1
YakPanel-server/backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# YakPanel - API routes
|
||||
Binary file not shown.
Binary file not shown.
90
YakPanel-server/backend/app/api/auth.py
Normal file
90
YakPanel-server/backend/app/api/auth.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""YakPanel - Auth API"""
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token, decode_token
|
||||
from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Get current authenticated user from JWT"""
|
||||
credentials_exception = HTTPException(status_code=401, detail="Invalid credentials")
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise credentials_exception
|
||||
sub = payload.get("sub")
|
||||
user_id = int(sub) if isinstance(sub, str) else sub
|
||||
if not user_id:
|
||||
raise credentials_exception
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="User inactive")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Login and return JWT token"""
|
||||
from sqlalchemy import select
|
||||
result = await db.execute(select(User).where(User.username == form_data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(form_data.password, user.password):
|
||||
raise HTTPException(status_code=401, detail="Incorrect username or password")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="User inactive")
|
||||
access_token = create_access_token(
|
||||
data={"sub": str(user.id)},
|
||||
expires_delta=timedelta(minutes=get_settings().access_token_expire_minutes),
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer", "user": {"id": user.id, "username": user.username}}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout (client should discard token)"""
|
||||
return {"message": "Logged out"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user info"""
|
||||
return {"id": current_user.id, "username": current_user.username, "email": current_user.email, "is_superuser": current_user.is_superuser}
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change password"""
|
||||
if not verify_password(body.old_password, current_user.password):
|
||||
raise HTTPException(status_code=400, detail="Incorrect current password")
|
||||
result = await db.execute(select(User).where(User.id == current_user.id))
|
||||
user = result.scalar_one()
|
||||
user.password = get_password_hash(body.new_password)
|
||||
await db.commit()
|
||||
return {"message": "Password changed"}
|
||||
204
YakPanel-server/backend/app/api/backup.py
Normal file
204
YakPanel-server/backend/app/api/backup.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""YakPanel - Backup plans API"""
|
||||
import os
|
||||
import tarfile
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from croniter import croniter
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.notification import send_email
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.site import Site
|
||||
from app.models.database import Database
|
||||
from app.models.backup_plan import BackupPlan
|
||||
from app.services.database_service import backup_mysql_database, backup_postgresql_database, backup_mongodb_database
|
||||
|
||||
router = APIRouter(prefix="/backup", tags=["backup"])
|
||||
|
||||
|
||||
class CreateBackupPlanRequest(BaseModel):
|
||||
name: str
|
||||
plan_type: str # site | database
|
||||
target_id: int
|
||||
schedule: str # cron, e.g. "0 2 * * *"
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
async def backup_plans_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all backup plans"""
|
||||
result = await db.execute(select(BackupPlan).order_by(BackupPlan.id))
|
||||
rows = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"plan_type": r.plan_type,
|
||||
"target_id": r.target_id,
|
||||
"schedule": r.schedule,
|
||||
"enabled": r.enabled,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/plans")
|
||||
async def backup_plan_create(
|
||||
body: CreateBackupPlanRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a backup plan"""
|
||||
if body.plan_type not in ("site", "database"):
|
||||
raise HTTPException(status_code=400, detail="plan_type must be site or database")
|
||||
if not body.schedule or len(body.schedule) < 9:
|
||||
raise HTTPException(status_code=400, detail="Invalid cron schedule")
|
||||
try:
|
||||
croniter(body.schedule)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid cron expression")
|
||||
if body.plan_type == "site":
|
||||
r = await db.execute(select(Site).where(Site.id == body.target_id))
|
||||
if not r.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
else:
|
||||
r = await db.execute(select(Database).where(Database.id == body.target_id))
|
||||
if not r.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
plan = BackupPlan(
|
||||
name=body.name,
|
||||
plan_type=body.plan_type,
|
||||
target_id=body.target_id,
|
||||
schedule=body.schedule,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
db.add(plan)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Backup plan created", "id": plan.id}
|
||||
|
||||
|
||||
@router.put("/plans/{plan_id}")
|
||||
async def backup_plan_update(
|
||||
plan_id: int,
|
||||
body: CreateBackupPlanRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a backup plan"""
|
||||
result = await db.execute(select(BackupPlan).where(BackupPlan.id == plan_id))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Backup plan not found")
|
||||
if body.schedule:
|
||||
try:
|
||||
croniter(body.schedule)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid cron expression")
|
||||
plan.name = body.name
|
||||
plan.plan_type = body.plan_type
|
||||
plan.target_id = body.target_id
|
||||
plan.schedule = body.schedule
|
||||
plan.enabled = body.enabled
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Updated"}
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}")
|
||||
async def backup_plan_delete(
|
||||
plan_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a backup plan"""
|
||||
result = await db.execute(select(BackupPlan).where(BackupPlan.id == plan_id))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Backup plan not found")
|
||||
await db.delete(plan)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Deleted"}
|
||||
|
||||
|
||||
def _run_site_backup(site: Site) -> tuple[bool, str, str | None]:
|
||||
"""Run site backup (sync, for use in run_scheduled). Returns (ok, msg, filename)."""
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = cfg["backup_path"]
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{site.name}_{ts}.tar.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
try:
|
||||
with tarfile.open(dest, "w:gz") as tf:
|
||||
tf.add(site.path, arcname=os.path.basename(site.path))
|
||||
return True, "Backup created", filename
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def _run_database_backup(dbo: Database) -> tuple[bool, str, str | None]:
|
||||
"""Run database backup (sync). Returns (ok, msg, filename)."""
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = os.path.join(cfg["backup_path"], "database")
|
||||
if dbo.db_type == "MySQL":
|
||||
return backup_mysql_database(dbo.name, backup_dir)
|
||||
if dbo.db_type == "PostgreSQL":
|
||||
return backup_postgresql_database(dbo.name, backup_dir)
|
||||
if dbo.db_type == "MongoDB":
|
||||
return backup_mongodb_database(dbo.name, backup_dir)
|
||||
return False, "Unsupported database type", None
|
||||
|
||||
|
||||
@router.post("/run-scheduled")
|
||||
async def backup_run_scheduled(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run all due backup plans. Call this from cron (e.g. every hour) or manually."""
|
||||
from datetime import datetime as dt
|
||||
now = dt.utcnow()
|
||||
result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True))
|
||||
plans = result.scalars().all()
|
||||
results = []
|
||||
for plan in plans:
|
||||
try:
|
||||
prev_run = croniter(plan.schedule, now).get_prev(dt)
|
||||
# Run if we're within 15 minutes after the scheduled time
|
||||
secs_since = (now - prev_run).total_seconds()
|
||||
if secs_since > 900 or secs_since < 0: # Not within 15 min window
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
if plan.plan_type == "site":
|
||||
r = await db.execute(select(Site).where(Site.id == plan.target_id))
|
||||
site = r.scalar_one_or_none()
|
||||
if not site or not os.path.isdir(site.path):
|
||||
results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"})
|
||||
continue
|
||||
ok, msg, filename = _run_site_backup(site)
|
||||
if ok:
|
||||
send_email(
|
||||
subject=f"YakPanel - Scheduled backup: {plan.name}",
|
||||
body=f"Site backup completed: {filename}\nSite: {site.name}",
|
||||
)
|
||||
else:
|
||||
r = await db.execute(select(Database).where(Database.id == plan.target_id))
|
||||
dbo = r.scalar_one_or_none()
|
||||
if not dbo:
|
||||
results.append({"plan": plan.name, "status": "skipped", "msg": "Database not found"})
|
||||
continue
|
||||
ok, msg, filename = _run_database_backup(dbo)
|
||||
if ok:
|
||||
send_email(
|
||||
subject=f"YakPanel - Scheduled backup: {plan.name}",
|
||||
body=f"Database backup completed: {filename}\nDatabase: {dbo.name}",
|
||||
)
|
||||
results.append({"plan": plan.name, "status": "ok" if ok else "failed", "msg": msg})
|
||||
return {"status": True, "results": results}
|
||||
83
YakPanel-server/backend/app/api/config.py
Normal file
83
YakPanel-server/backend/app/api/config.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""YakPanel - Config/Settings API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_settings, get_runtime_config, set_runtime_config_overrides
|
||||
from app.core.notification import send_email
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.config import Config
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("/panel")
|
||||
async def get_panel_config(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get panel configuration (DB overrides applied)"""
|
||||
s = get_settings()
|
||||
cfg = get_runtime_config()
|
||||
return {
|
||||
"panel_port": cfg["panel_port"],
|
||||
"www_root": cfg["www_root"],
|
||||
"setup_path": cfg["setup_path"],
|
||||
"webserver_type": cfg["webserver_type"],
|
||||
"mysql_root_set": bool(cfg.get("mysql_root")),
|
||||
"app_name": s.app_name,
|
||||
"app_version": s.app_version,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def get_config_keys(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all config key-value pairs from DB"""
|
||||
result = await db.execute(select(Config).order_by(Config.key))
|
||||
rows = result.scalars().all()
|
||||
return {r.key: r.value for r in rows}
|
||||
|
||||
|
||||
@router.post("/set")
|
||||
async def set_config(
|
||||
body: ConfigUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set config value (stored in DB, runtime updated)"""
|
||||
result = await db.execute(select(Config).where(Config.key == body.key))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = body.value
|
||||
else:
|
||||
db.add(Config(key=body.key, value=body.value))
|
||||
await db.commit()
|
||||
# Reload runtime config so changes take effect without restart
|
||||
r2 = await db.execute(select(Config))
|
||||
overrides = {r.key: r.value for r in r2.scalars().all() if r.value is not None}
|
||||
set_runtime_config_overrides(overrides)
|
||||
return {"status": True, "msg": "Saved"}
|
||||
|
||||
|
||||
@router.post("/test-email")
|
||||
async def test_email(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Send a test email to verify SMTP configuration"""
|
||||
ok, msg = send_email(
|
||||
subject="YakPanel - Test Email",
|
||||
body="This is a test email from YakPanel. If you received this, your email configuration is working.",
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
return {"status": True, "msg": "Test email sent"}
|
||||
113
YakPanel-server/backend/app/api/crontab.py
Normal file
113
YakPanel-server/backend/app/api/crontab.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""YakPanel - Crontab API"""
|
||||
import tempfile
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.crontab import Crontab
|
||||
|
||||
router = APIRouter(prefix="/crontab", tags=["crontab"])
|
||||
|
||||
|
||||
class CreateCrontabRequest(BaseModel):
|
||||
name: str = ""
|
||||
type: str = "shell"
|
||||
schedule: str
|
||||
execstr: str
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def crontab_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List cron jobs"""
|
||||
result = await db.execute(select(Crontab).order_by(Crontab.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "name": r.name, "type": r.type, "schedule": r.schedule, "execstr": r.execstr} for r in rows]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def crontab_create(
|
||||
body: CreateCrontabRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create cron job"""
|
||||
cron = Crontab(name=body.name, type=body.type, schedule=body.schedule, execstr=body.execstr)
|
||||
db.add(cron)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Cron job created", "id": cron.id}
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def crontab_apply(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Sync panel cron jobs to system crontab (root)"""
|
||||
result = await db.execute(select(Crontab).order_by(Crontab.id))
|
||||
rows = result.scalars().all()
|
||||
lines = [
|
||||
"# YakPanel managed crontab - do not edit manually",
|
||||
"",
|
||||
]
|
||||
for r in rows:
|
||||
if r.name:
|
||||
lines.append(f"# {r.name}")
|
||||
lines.append(f"{r.schedule} {r.execstr}")
|
||||
lines.append("")
|
||||
content = "\n".join(lines).strip() + "\n"
|
||||
fd, path = tempfile.mkstemp(suffix=".crontab", prefix="cit_")
|
||||
try:
|
||||
os.write(fd, content.encode("utf-8"))
|
||||
os.close(fd)
|
||||
out, err = exec_shell_sync(f"crontab {path}", timeout=10)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
finally:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
return {"status": True, "msg": "Crontab applied", "count": len(rows)}
|
||||
|
||||
|
||||
@router.put("/{cron_id}")
|
||||
async def crontab_update(
|
||||
cron_id: int,
|
||||
body: CreateCrontabRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update cron job"""
|
||||
result = await db.execute(select(Crontab).where(Crontab.id == cron_id))
|
||||
cron = result.scalar_one_or_none()
|
||||
if not cron:
|
||||
raise HTTPException(status_code=404, detail="Cron job not found")
|
||||
cron.name = body.name
|
||||
cron.type = body.type
|
||||
cron.schedule = body.schedule
|
||||
cron.execstr = body.execstr
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Cron job updated"}
|
||||
|
||||
|
||||
@router.delete("/{cron_id}")
|
||||
async def crontab_delete(
|
||||
cron_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete cron job"""
|
||||
result = await db.execute(select(Crontab).where(Crontab.id == cron_id))
|
||||
cron = result.scalar_one_or_none()
|
||||
if not cron:
|
||||
raise HTTPException(status_code=404, detail="Cron job not found")
|
||||
await db.delete(cron)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Cron job deleted"}
|
||||
49
YakPanel-server/backend/app/api/dashboard.py
Normal file
49
YakPanel-server/backend/app/api/dashboard.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""YakPanel - Dashboard API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get dashboard statistics"""
|
||||
import psutil
|
||||
|
||||
from app.services.site_service import get_site_count
|
||||
from app.models.ftp import Ftp
|
||||
from app.models.database import Database
|
||||
from sqlalchemy import select, func
|
||||
site_count = await get_site_count(db)
|
||||
ftp_result = await db.execute(select(func.count()).select_from(Ftp))
|
||||
ftp_count = ftp_result.scalar() or 0
|
||||
db_result = await db.execute(select(func.count()).select_from(Database))
|
||||
database_count = db_result.scalar() or 0
|
||||
|
||||
# System stats
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage("/")
|
||||
|
||||
return {
|
||||
"site_count": site_count,
|
||||
"ftp_count": ftp_count,
|
||||
"database_count": database_count,
|
||||
"system": {
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory_percent": memory.percent,
|
||||
"memory_used_mb": round(memory.used / 1024 / 1024, 1),
|
||||
"memory_total_mb": round(memory.total / 1024 / 1024, 1),
|
||||
"disk_percent": disk.percent,
|
||||
"disk_used_gb": round(disk.used / 1024 / 1024 / 1024, 2),
|
||||
"disk_total_gb": round(disk.total / 1024 / 1024 / 1024, 2),
|
||||
},
|
||||
}
|
||||
274
YakPanel-server/backend/app/api/database.py
Normal file
274
YakPanel-server/backend/app/api/database.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""YakPanel - Database API"""
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.notification import send_email
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.database import Database
|
||||
from app.services.database_service import (
|
||||
create_mysql_database,
|
||||
drop_mysql_database,
|
||||
backup_mysql_database,
|
||||
restore_mysql_database,
|
||||
change_mysql_password,
|
||||
create_postgresql_database,
|
||||
drop_postgresql_database,
|
||||
backup_postgresql_database,
|
||||
restore_postgresql_database,
|
||||
change_postgresql_password,
|
||||
create_mongodb_database,
|
||||
drop_mongodb_database,
|
||||
backup_mongodb_database,
|
||||
restore_mongodb_database,
|
||||
change_mongodb_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/database", tags=["database"])
|
||||
|
||||
|
||||
class CreateDatabaseRequest(BaseModel):
|
||||
name: str
|
||||
username: str
|
||||
password: str
|
||||
db_type: str = "MySQL"
|
||||
ps: str = ""
|
||||
|
||||
|
||||
class UpdateDbPasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def database_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List databases"""
|
||||
result = await db.execute(select(Database).order_by(Database.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "name": r.name, "username": r.username, "db_type": r.db_type, "ps": r.ps} for r in rows]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def database_create(
|
||||
body: CreateDatabaseRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create database (panel DB + actual MySQL/PostgreSQL when supported)"""
|
||||
result = await db.execute(select(Database).where(Database.name == body.name))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Database already exists")
|
||||
if body.db_type == "MySQL":
|
||||
ok, msg = create_mysql_database(body.name, body.username, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
elif body.db_type == "PostgreSQL":
|
||||
ok, msg = create_postgresql_database(body.name, body.username, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
elif body.db_type == "MongoDB":
|
||||
ok, msg = create_mongodb_database(body.name, body.username, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
dbo = Database(
|
||||
name=body.name,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
db_type=body.db_type,
|
||||
ps=body.ps,
|
||||
)
|
||||
db.add(dbo)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Database created", "id": dbo.id}
|
||||
|
||||
|
||||
@router.put("/{db_id}/password")
|
||||
async def database_update_password(
|
||||
db_id: int,
|
||||
body: UpdateDbPasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change database user password (MySQL, PostgreSQL, or MongoDB)"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
if dbo.db_type not in ("MySQL", "PostgreSQL", "MongoDB"):
|
||||
raise HTTPException(status_code=400, detail="Only MySQL, PostgreSQL, and MongoDB supported")
|
||||
if not body.password or len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
if dbo.db_type == "MySQL":
|
||||
ok, msg = change_mysql_password(dbo.username, body.password)
|
||||
elif dbo.db_type == "PostgreSQL":
|
||||
ok, msg = change_postgresql_password(dbo.username, body.password)
|
||||
else:
|
||||
ok, msg = change_mongodb_password(dbo.username, dbo.name, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
dbo.password = body.password
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Password updated"}
|
||||
|
||||
|
||||
@router.delete("/{db_id}")
|
||||
async def database_delete(
|
||||
db_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete database (panel record + actual MySQL/PostgreSQL when supported)"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
if dbo.db_type == "MySQL":
|
||||
ok, msg = drop_mysql_database(dbo.name, dbo.username)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
elif dbo.db_type == "PostgreSQL":
|
||||
ok, msg = drop_postgresql_database(dbo.name, dbo.username)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
elif dbo.db_type == "MongoDB":
|
||||
ok, msg = drop_mongodb_database(dbo.name, dbo.username)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
await db.delete(dbo)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Database deleted"}
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def database_count(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get database count"""
|
||||
result = await db.execute(select(func.count()).select_from(Database))
|
||||
return {"count": result.scalar() or 0}
|
||||
|
||||
|
||||
@router.post("/{db_id}/backup")
|
||||
async def database_backup(
|
||||
db_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create database backup (MySQL: mysqldump, PostgreSQL: pg_dump, MongoDB: mongodump)"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
if dbo.db_type not in ("MySQL", "PostgreSQL", "MongoDB"):
|
||||
raise HTTPException(status_code=400, detail="Backup not supported for this database type")
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = os.path.join(cfg["backup_path"], "database")
|
||||
if dbo.db_type == "MySQL":
|
||||
ok, msg, filename = backup_mysql_database(dbo.name, backup_dir)
|
||||
elif dbo.db_type == "PostgreSQL":
|
||||
ok, msg, filename = backup_postgresql_database(dbo.name, backup_dir)
|
||||
else:
|
||||
ok, msg, filename = backup_mongodb_database(dbo.name, backup_dir)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=msg)
|
||||
send_email(
|
||||
subject=f"YakPanel - Database backup: {dbo.name}",
|
||||
body=f"Backup completed: {filename}\nDatabase: {dbo.name}",
|
||||
)
|
||||
return {"status": True, "msg": "Backup created", "filename": filename}
|
||||
|
||||
|
||||
@router.get("/{db_id}/backups")
|
||||
async def database_backups_list(
|
||||
db_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List backups for a database"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = os.path.join(cfg["backup_path"], "database")
|
||||
if not os.path.isdir(backup_dir):
|
||||
return {"backups": []}
|
||||
prefix = f"{dbo.name}_"
|
||||
backups = []
|
||||
for f in os.listdir(backup_dir):
|
||||
if f.startswith(prefix) and f.endswith(".sql.gz"):
|
||||
p = os.path.join(backup_dir, f)
|
||||
backups.append({"filename": f, "size": os.path.getsize(p) if os.path.isfile(p) else 0})
|
||||
backups.sort(key=lambda x: x["filename"], reverse=True)
|
||||
return {"backups": backups}
|
||||
|
||||
|
||||
@router.get("/{db_id}/backups/download")
|
||||
async def database_backup_download(
|
||||
db_id: int,
|
||||
file: str = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download database backup"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{dbo.name}_"):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
if not (file.endswith(".sql.gz") or file.endswith(".tar.gz")):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
cfg = get_runtime_config()
|
||||
path = os.path.join(cfg["backup_path"], "database", file)
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
return FileResponse(path, filename=file)
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
filename: str
|
||||
|
||||
|
||||
@router.post("/{db_id}/restore")
|
||||
async def database_restore(
|
||||
db_id: int,
|
||||
body: RestoreRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Restore database from backup"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
file = body.filename
|
||||
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{dbo.name}_"):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
valid_ext = file.endswith(".sql.gz") or file.endswith(".tar.gz")
|
||||
if not valid_ext:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
if dbo.db_type in ("MySQL", "PostgreSQL") and not file.endswith(".sql.gz"):
|
||||
raise HTTPException(status_code=400, detail="Wrong backup format for this database type")
|
||||
if dbo.db_type == "MongoDB" and not file.endswith(".tar.gz"):
|
||||
raise HTTPException(status_code=400, detail="Wrong backup format for MongoDB")
|
||||
cfg = get_runtime_config()
|
||||
backup_path = os.path.join(cfg["backup_path"], "database", file)
|
||||
if dbo.db_type == "MySQL":
|
||||
ok, msg = restore_mysql_database(dbo.name, backup_path)
|
||||
elif dbo.db_type == "PostgreSQL":
|
||||
ok, msg = restore_postgresql_database(dbo.name, backup_path)
|
||||
else:
|
||||
ok, msg = restore_mongodb_database(dbo.name, backup_path)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=msg)
|
||||
return {"status": True, "msg": "Restored"}
|
||||
162
YakPanel-server/backend/app/api/docker.py
Normal file
162
YakPanel-server/backend/app/api/docker.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""YakPanel - Docker API - list/start/stop containers via docker CLI"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
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="/docker", tags=["docker"])
|
||||
|
||||
|
||||
class RunContainerRequest(BaseModel):
|
||||
image: str
|
||||
name: str = ""
|
||||
ports: str = ""
|
||||
cmd: str = ""
|
||||
|
||||
|
||||
@router.get("/containers")
|
||||
async def docker_containers(current_user: User = Depends(get_current_user)):
|
||||
"""List Docker containers (docker ps -a)"""
|
||||
out, err = exec_shell_sync(
|
||||
'docker ps -a --format "{{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}\t{{.Ports}}"',
|
||||
timeout=10,
|
||||
)
|
||||
if err and "Cannot connect" in err:
|
||||
return {"containers": [], "error": "Docker not available"}
|
||||
containers = []
|
||||
for line in out.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t", 4)
|
||||
if len(parts) >= 5:
|
||||
containers.append({
|
||||
"id": parts[0][:12],
|
||||
"id_full": parts[0],
|
||||
"image": parts[1],
|
||||
"status": parts[2],
|
||||
"names": parts[3],
|
||||
"ports": parts[4],
|
||||
})
|
||||
elif len(parts) >= 4:
|
||||
containers.append({
|
||||
"id": parts[0][:12],
|
||||
"id_full": parts[0],
|
||||
"image": parts[1],
|
||||
"status": parts[2],
|
||||
"names": parts[3],
|
||||
"ports": "",
|
||||
})
|
||||
return {"containers": containers}
|
||||
|
||||
|
||||
@router.get("/images")
|
||||
async def docker_images(current_user: User = Depends(get_current_user)):
|
||||
"""List Docker images (docker images)"""
|
||||
out, err = exec_shell_sync(
|
||||
'docker images --format "{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}"',
|
||||
timeout=10,
|
||||
)
|
||||
if err and "Cannot connect" in err:
|
||||
return {"images": [], "error": "Docker not available"}
|
||||
images = []
|
||||
for line in out.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t", 3)
|
||||
if len(parts) >= 4:
|
||||
images.append({
|
||||
"repository": parts[0],
|
||||
"tag": parts[1],
|
||||
"id": parts[2],
|
||||
"size": parts[3],
|
||||
})
|
||||
return {"images": images}
|
||||
|
||||
|
||||
@router.post("/pull")
|
||||
async def docker_pull(
|
||||
image: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Pull Docker image (docker pull)"""
|
||||
if not image or " " in image or "'" in image or '"' in image:
|
||||
raise HTTPException(status_code=400, detail="Invalid image name")
|
||||
out, err = exec_shell_sync(f"docker pull {image}", timeout=600)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Pulled"}
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def docker_run(
|
||||
body: RunContainerRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Run a new container (docker run -d)"""
|
||||
image = (body.image or "").strip()
|
||||
if not image:
|
||||
raise HTTPException(status_code=400, detail="Image required")
|
||||
if " " in image or "'" in image or '"' in image:
|
||||
raise HTTPException(status_code=400, detail="Invalid image name")
|
||||
cmd = f"docker run -d {image}"
|
||||
if body.name:
|
||||
name = body.name.strip().replace(" ", "-")
|
||||
if name and all(c.isalnum() or c in "-_" for c in name):
|
||||
cmd += f" --name {name}"
|
||||
if body.ports:
|
||||
for p in body.ports.replace(",", " ").split():
|
||||
p = p.strip()
|
||||
if p:
|
||||
cmd += f" -p {p}" if ":" in p else f" -p {p}:{p}"
|
||||
if body.cmd:
|
||||
cmd += f" {body.cmd}"
|
||||
out, err = exec_shell_sync(cmd, timeout=60)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Container started", "id": out.strip()[:12]}
|
||||
|
||||
|
||||
@router.post("/{container_id}/start")
|
||||
async def docker_start(
|
||||
container_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Start container"""
|
||||
if " " in container_id or "'" in container_id or '"' in container_id:
|
||||
raise HTTPException(status_code=400, detail="Invalid container ID")
|
||||
out, err = exec_shell_sync(f"docker start {container_id}", timeout=30)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Started"}
|
||||
|
||||
|
||||
@router.post("/{container_id}/stop")
|
||||
async def docker_stop(
|
||||
container_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Stop container"""
|
||||
if " " in container_id or "'" in container_id or '"' in container_id:
|
||||
raise HTTPException(status_code=400, detail="Invalid container ID")
|
||||
out, err = exec_shell_sync(f"docker stop {container_id}", timeout=30)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Stopped"}
|
||||
|
||||
|
||||
@router.post("/{container_id}/restart")
|
||||
async def docker_restart(
|
||||
container_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Restart container"""
|
||||
if " " in container_id or "'" in container_id or '"' in container_id:
|
||||
raise HTTPException(status_code=400, detail="Invalid container ID")
|
||||
out, err = exec_shell_sync(f"docker restart {container_id}", timeout=30)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Restarted"}
|
||||
240
YakPanel-server/backend/app/api/files.py
Normal file
240
YakPanel-server/backend/app/api/files.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""YakPanel - File manager API"""
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import read_file, write_file, path_safe_check
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/files", tags=["files"])
|
||||
|
||||
|
||||
def _resolve_path(path: str) -> str:
|
||||
"""Resolve and validate path within allowed roots (cross-platform)"""
|
||||
cfg = get_runtime_config()
|
||||
www_root = os.path.abspath(cfg["www_root"])
|
||||
setup_path = os.path.abspath(cfg["setup_path"])
|
||||
allowed = [www_root, setup_path]
|
||||
if os.name != "nt":
|
||||
allowed.append(os.path.abspath("/www"))
|
||||
if ".." in path:
|
||||
raise HTTPException(status_code=401, detail="Path traversal not allowed")
|
||||
norm_path = path.strip().replace("\\", "/").strip("/")
|
||||
# Root or www_root-style path
|
||||
if not norm_path or norm_path in ("www", "www/wwwroot", "wwwroot"):
|
||||
full = www_root
|
||||
elif norm_path.startswith("www/wwwroot/"):
|
||||
full = os.path.abspath(os.path.join(www_root, norm_path[12:]))
|
||||
else:
|
||||
full = os.path.abspath(os.path.join(www_root, norm_path))
|
||||
if not any(
|
||||
full == r or (full + os.sep).startswith(r + os.sep)
|
||||
for r in allowed
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Path not allowed")
|
||||
return full
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def files_list(
|
||||
path: str = "/",
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List directory contents"""
|
||||
try:
|
||||
full = _resolve_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isdir(full):
|
||||
raise HTTPException(status_code=401, detail="Not a directory")
|
||||
items = []
|
||||
for name in os.listdir(full):
|
||||
item_path = os.path.join(full, name)
|
||||
try:
|
||||
stat = os.stat(item_path)
|
||||
items.append({
|
||||
"name": name,
|
||||
"is_dir": os.path.isdir(item_path),
|
||||
"size": stat.st_size if os.path.isfile(item_path) else 0,
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
return {"path": path, "items": items}
|
||||
|
||||
|
||||
@router.get("/read")
|
||||
async def files_read(
|
||||
path: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Read file content"""
|
||||
try:
|
||||
full = _resolve_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isfile(full):
|
||||
raise HTTPException(status_code=404, detail="Not a file")
|
||||
content = read_file(full)
|
||||
if content is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to read file")
|
||||
return {"path": path, "content": content}
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
async def files_download(
|
||||
path: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Download file"""
|
||||
try:
|
||||
full = _resolve_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isfile(full):
|
||||
raise HTTPException(status_code=404, detail="Not a file")
|
||||
return FileResponse(full, filename=os.path.basename(full))
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def files_upload(
|
||||
path: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload file to directory"""
|
||||
try:
|
||||
full = _resolve_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isdir(full):
|
||||
raise HTTPException(status_code=400, detail="Path must be a directory")
|
||||
filename = file.filename or "upload"
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
dest = os.path.join(full, filename)
|
||||
content = await file.read()
|
||||
if not write_file(dest, content, "wb"):
|
||||
raise HTTPException(status_code=500, detail="Failed to write file")
|
||||
return {"status": True, "msg": "Uploaded", "path": path + "/" + filename}
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
|
||||
|
||||
class RenameRequest(BaseModel):
|
||||
path: str
|
||||
old_name: str
|
||||
new_name: str
|
||||
|
||||
|
||||
class DeleteRequest(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
is_dir: bool
|
||||
|
||||
|
||||
@router.post("/mkdir")
|
||||
async def files_mkdir(
|
||||
body: MkdirRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create directory"""
|
||||
try:
|
||||
parent = _resolve_path(body.path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isdir(parent):
|
||||
raise HTTPException(status_code=400, detail="Parent must be a directory")
|
||||
if not body.name or ".." in body.name or "/" in body.name or "\\" in body.name:
|
||||
raise HTTPException(status_code=400, detail="Invalid directory name")
|
||||
if not path_safe_check(body.name):
|
||||
raise HTTPException(status_code=400, detail="Invalid directory name")
|
||||
full = os.path.join(parent, body.name)
|
||||
if os.path.exists(full):
|
||||
raise HTTPException(status_code=400, detail="Already exists")
|
||||
try:
|
||||
os.makedirs(full, 0o755)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"status": True, "msg": "Created", "path": body.path.rstrip("/") + "/" + body.name}
|
||||
|
||||
|
||||
@router.post("/rename")
|
||||
async def files_rename(
|
||||
body: RenameRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename file or directory"""
|
||||
try:
|
||||
parent = _resolve_path(body.path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not body.old_name or not body.new_name:
|
||||
raise HTTPException(status_code=400, detail="Names required")
|
||||
for n in (body.old_name, body.new_name):
|
||||
if ".." in n or "/" in n or "\\" in n or not path_safe_check(n):
|
||||
raise HTTPException(status_code=400, detail="Invalid name")
|
||||
old_full = os.path.join(parent, body.old_name)
|
||||
new_full = os.path.join(parent, body.new_name)
|
||||
if not os.path.exists(old_full):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if os.path.exists(new_full):
|
||||
raise HTTPException(status_code=400, detail="Target already exists")
|
||||
try:
|
||||
os.rename(old_full, new_full)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"status": True, "msg": "Renamed"}
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
async def files_delete(
|
||||
body: DeleteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete file or directory"""
|
||||
try:
|
||||
parent = _resolve_path(body.path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not body.name or ".." in body.name or "/" in body.name or "\\" in body.name:
|
||||
raise HTTPException(status_code=400, detail="Invalid name")
|
||||
full = os.path.join(parent, body.name)
|
||||
if not os.path.exists(full):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
try:
|
||||
if body.is_dir:
|
||||
import shutil
|
||||
shutil.rmtree(full)
|
||||
else:
|
||||
os.remove(full)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"status": True, "msg": "Deleted"}
|
||||
|
||||
|
||||
class WriteFileRequest(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
@router.post("/write")
|
||||
async def files_write(
|
||||
body: WriteFileRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Write text file content"""
|
||||
try:
|
||||
full = _resolve_path(body.path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if os.path.isdir(full):
|
||||
raise HTTPException(status_code=400, detail="Cannot write to directory")
|
||||
if not write_file(full, body.content):
|
||||
raise HTTPException(status_code=500, detail="Failed to write file")
|
||||
return {"status": True, "msg": "Saved"}
|
||||
83
YakPanel-server/backend/app/api/firewall.py
Normal file
83
YakPanel-server/backend/app/api/firewall.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""YakPanel - Firewall API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.firewall import FirewallRule
|
||||
|
||||
router = APIRouter(prefix="/firewall", tags=["firewall"])
|
||||
|
||||
|
||||
class CreateFirewallRuleRequest(BaseModel):
|
||||
port: str
|
||||
protocol: str = "tcp"
|
||||
action: str = "accept"
|
||||
ps: str = ""
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def firewall_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List firewall rules"""
|
||||
result = await db.execute(select(FirewallRule).order_by(FirewallRule.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "port": r.port, "protocol": r.protocol, "action": r.action, "ps": r.ps} for r in rows]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def firewall_create(
|
||||
body: CreateFirewallRuleRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add firewall rule (stored in panel; use Apply to UFW to sync)"""
|
||||
if not body.port or len(body.port) > 32:
|
||||
raise HTTPException(status_code=400, detail="Invalid port")
|
||||
rule = FirewallRule(port=body.port, protocol=body.protocol, action=body.action, ps=body.ps)
|
||||
db.add(rule)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Rule added", "id": rule.id}
|
||||
|
||||
|
||||
@router.delete("/{rule_id}")
|
||||
async def firewall_delete(
|
||||
rule_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete firewall rule"""
|
||||
result = await db.execute(select(FirewallRule).where(FirewallRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Rule deleted"}
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def firewall_apply(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Apply firewall rules to ufw (runs ufw allow/deny for each rule)"""
|
||||
result = await db.execute(select(FirewallRule).order_by(FirewallRule.id))
|
||||
rules = result.scalars().all()
|
||||
errors = []
|
||||
for r in rules:
|
||||
port_proto = f"{r.port}/{r.protocol}"
|
||||
action = "allow" if r.action == "accept" else "deny"
|
||||
cmd = f"ufw {action} {port_proto}"
|
||||
out, err = exec_shell_sync(cmd, timeout=10)
|
||||
if err and "error" in err.lower() and "already" not in err.lower():
|
||||
errors.append(f"{port_proto}: {err.strip()}")
|
||||
if errors:
|
||||
raise HTTPException(status_code=500, detail="; ".join(errors))
|
||||
return {"status": True, "msg": "Rules applied", "count": len(rules)}
|
||||
113
YakPanel-server/backend/app/api/ftp.py
Normal file
113
YakPanel-server/backend/app/api/ftp.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""YakPanel - FTP API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_password_hash
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.ftp import Ftp
|
||||
from app.services.ftp_service import create_ftp_user, delete_ftp_user, update_ftp_password
|
||||
|
||||
router = APIRouter(prefix="/ftp", tags=["ftp"])
|
||||
|
||||
|
||||
class CreateFtpRequest(BaseModel):
|
||||
name: str
|
||||
password: str
|
||||
path: str
|
||||
pid: int = 0
|
||||
ps: str = ""
|
||||
|
||||
|
||||
class UpdateFtpPasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def ftp_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List FTP accounts"""
|
||||
result = await db.execute(select(Ftp).order_by(Ftp.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "name": r.name, "path": r.path, "ps": r.ps} for r in rows]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def ftp_create(
|
||||
body: CreateFtpRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create FTP account (panel + Pure-FTPd when available)"""
|
||||
result = await db.execute(select(Ftp).where(Ftp.name == body.name))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="FTP account already exists")
|
||||
ok, msg = create_ftp_user(body.name, body.password, body.path)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"FTP: {msg}")
|
||||
ftp = Ftp(
|
||||
name=body.name,
|
||||
password=get_password_hash(body.password),
|
||||
path=body.path,
|
||||
pid=body.pid,
|
||||
ps=body.ps,
|
||||
)
|
||||
db.add(ftp)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "FTP account created", "id": ftp.id}
|
||||
|
||||
|
||||
@router.put("/{ftp_id}/password")
|
||||
async def ftp_update_password(
|
||||
ftp_id: int,
|
||||
body: UpdateFtpPasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update FTP account password"""
|
||||
result = await db.execute(select(Ftp).where(Ftp.id == ftp_id))
|
||||
ftp = result.scalar_one_or_none()
|
||||
if not ftp:
|
||||
raise HTTPException(status_code=404, detail="FTP account not found")
|
||||
if not body.password or len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
ok, msg = update_ftp_password(ftp.name, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"FTP: {msg}")
|
||||
ftp.password = get_password_hash(body.password)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Password updated"}
|
||||
|
||||
|
||||
@router.delete("/{ftp_id}")
|
||||
async def ftp_delete(
|
||||
ftp_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete FTP account (panel + Pure-FTPd when available)"""
|
||||
result = await db.execute(select(Ftp).where(Ftp.id == ftp_id))
|
||||
ftp = result.scalar_one_or_none()
|
||||
if not ftp:
|
||||
raise HTTPException(status_code=404, detail="FTP account not found")
|
||||
ok, msg = delete_ftp_user(ftp.name)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"FTP: {msg}")
|
||||
await db.delete(ftp)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "FTP account deleted"}
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def ftp_count(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get FTP count"""
|
||||
result = await db.execute(select(func.count()).select_from(Ftp))
|
||||
return {"count": result.scalar() or 0}
|
||||
80
YakPanel-server/backend/app/api/logs.py
Normal file
80
YakPanel-server/backend/app/api/logs.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""YakPanel - Logs viewer API"""
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import read_file
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
|
||||
def _resolve_log_path(path: str) -> str:
|
||||
"""Resolve path within www_logs only"""
|
||||
if ".." in path:
|
||||
raise HTTPException(status_code=401, detail="Path traversal not allowed")
|
||||
cfg = get_runtime_config()
|
||||
logs_root = os.path.abspath(cfg["www_logs"])
|
||||
path = path.strip().replace("\\", "/").lstrip("/")
|
||||
if not path:
|
||||
return logs_root
|
||||
full = os.path.abspath(os.path.join(logs_root, path))
|
||||
if not (full == logs_root or full.startswith(logs_root + os.sep)):
|
||||
raise HTTPException(status_code=403, detail="Path not allowed")
|
||||
return full
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def logs_list(
|
||||
path: str = "/",
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List log files and directories under www_logs"""
|
||||
try:
|
||||
full = _resolve_log_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isdir(full):
|
||||
raise HTTPException(status_code=400, detail="Not a directory")
|
||||
items = []
|
||||
for name in sorted(os.listdir(full)):
|
||||
item_path = os.path.join(full, name)
|
||||
try:
|
||||
stat = os.stat(item_path)
|
||||
items.append({
|
||||
"name": name,
|
||||
"is_dir": os.path.isdir(item_path),
|
||||
"size": stat.st_size if os.path.isfile(item_path) else 0,
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
rel = path.rstrip("/") or "/"
|
||||
return {"path": rel, "items": items}
|
||||
|
||||
|
||||
@router.get("/read")
|
||||
async def logs_read(
|
||||
path: str,
|
||||
tail: int = Query(default=1000, ge=1, le=100000),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Read log file content (last N lines)"""
|
||||
try:
|
||||
full = _resolve_log_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isfile(full):
|
||||
raise HTTPException(status_code=404, detail="Not a file")
|
||||
content = read_file(full)
|
||||
if content is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to read file")
|
||||
if isinstance(content, bytes):
|
||||
try:
|
||||
content = content.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Binary file")
|
||||
lines = content.splitlines()
|
||||
if len(lines) > tail:
|
||||
lines = lines[-tail:]
|
||||
return {"path": path, "content": "\n".join(lines), "total_lines": len(content.splitlines())}
|
||||
65
YakPanel-server/backend/app/api/monitor.py
Normal file
65
YakPanel-server/backend/app/api/monitor.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""YakPanel - Monitor API"""
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/monitor", tags=["monitor"])
|
||||
|
||||
|
||||
@router.get("/system")
|
||||
async def monitor_system(current_user: User = Depends(get_current_user)):
|
||||
"""Get system stats"""
|
||||
cpu = psutil.cpu_percent(interval=1)
|
||||
mem = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage("/")
|
||||
return {
|
||||
"cpu_percent": cpu,
|
||||
"memory_percent": mem.percent,
|
||||
"memory_used_mb": round(mem.used / 1024 / 1024, 1),
|
||||
"memory_total_mb": round(mem.total / 1024 / 1024, 1),
|
||||
"disk_percent": disk.percent,
|
||||
"disk_used_gb": round(disk.used / 1024 / 1024 / 1024, 2),
|
||||
"disk_total_gb": round(disk.total / 1024 / 1024 / 1024, 2),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/processes")
|
||||
async def monitor_processes(
|
||||
current_user: User = Depends(get_current_user),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""Get top processes by CPU usage"""
|
||||
procs = []
|
||||
for p in psutil.process_iter(["pid", "name", "username", "cpu_percent", "memory_percent", "status"]):
|
||||
try:
|
||||
info = p.info
|
||||
cpu = info.get("cpu_percent") or 0
|
||||
mem = info.get("memory_percent") or 0
|
||||
procs.append({
|
||||
"pid": info.get("pid"),
|
||||
"name": info.get("name") or "",
|
||||
"username": info.get("username") or "",
|
||||
"cpu_percent": round(cpu, 1),
|
||||
"memory_percent": round(mem, 1),
|
||||
"status": info.get("status") or "",
|
||||
})
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
procs.sort(key=lambda x: (x["cpu_percent"] or 0), reverse=True)
|
||||
return {"processes": procs[:limit]}
|
||||
|
||||
|
||||
@router.get("/network")
|
||||
async def monitor_network(current_user: User = Depends(get_current_user)):
|
||||
"""Get network I/O stats"""
|
||||
net = psutil.net_io_counters()
|
||||
return {
|
||||
"bytes_sent": net.bytes_sent,
|
||||
"bytes_recv": net.bytes_recv,
|
||||
"packets_sent": net.packets_sent,
|
||||
"packets_recv": net.packets_recv,
|
||||
"bytes_sent_mb": round(net.bytes_sent / 1024 / 1024, 2),
|
||||
"bytes_recv_mb": round(net.bytes_recv / 1024 / 1024, 2),
|
||||
}
|
||||
136
YakPanel-server/backend/app/api/node.py
Normal file
136
YakPanel-server/backend/app/api/node.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""YakPanel - Node.js / PM2 API"""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
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="/node", tags=["node"])
|
||||
|
||||
|
||||
class AddProcessRequest(BaseModel):
|
||||
script: str
|
||||
name: str = ""
|
||||
|
||||
|
||||
@router.get("/processes")
|
||||
async def node_processes(current_user: User = Depends(get_current_user)):
|
||||
"""List PM2 processes (pm2 jlist)"""
|
||||
out, err = exec_shell_sync("pm2 jlist", timeout=10)
|
||||
if err and "not found" in err.lower():
|
||||
return {"processes": [], "error": "PM2 not installed"}
|
||||
try:
|
||||
data = json.loads(out) if out.strip() else []
|
||||
processes = []
|
||||
now_ms = int(time.time() * 1000)
|
||||
for p in data if isinstance(data, list) else []:
|
||||
name = p.get("name", "")
|
||||
pm2_env = p.get("pm2_env", {})
|
||||
start_ms = pm2_env.get("pm_uptime", 0)
|
||||
uptime_ms = (now_ms - start_ms) if start_ms and pm2_env.get("status") == "online" else 0
|
||||
processes.append({
|
||||
"id": p.get("pm_id"),
|
||||
"name": name,
|
||||
"status": pm2_env.get("status", "unknown"),
|
||||
"pid": p.get("pid"),
|
||||
"uptime": uptime_ms,
|
||||
"restarts": pm2_env.get("restart_time", 0),
|
||||
"memory": p.get("monit", {}).get("memory", 0),
|
||||
"cpu": p.get("monit", {}).get("cpu", 0),
|
||||
})
|
||||
return {"processes": processes}
|
||||
except json.JSONDecodeError:
|
||||
return {"processes": [], "error": "Failed to parse PM2 output"}
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def node_add(
|
||||
body: AddProcessRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add and start a new PM2 process (pm2 start script --name name)"""
|
||||
script = (body.script or "").strip()
|
||||
if not script:
|
||||
raise HTTPException(status_code=400, detail="Script path required")
|
||||
if ".." in script or ";" in script or "|" in script or "`" in script:
|
||||
raise HTTPException(status_code=400, detail="Invalid script path")
|
||||
name = (body.name or "").strip()
|
||||
if name and ("'" in name or '"' in name or ";" in name):
|
||||
raise HTTPException(status_code=400, detail="Invalid process name")
|
||||
cmd = f"pm2 start {script}"
|
||||
if name:
|
||||
cmd += f" --name '{name}'"
|
||||
out, err = exec_shell_sync(cmd, timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Process started"}
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def node_version(current_user: User = Depends(get_current_user)):
|
||||
"""Get Node.js version"""
|
||||
out, err = exec_shell_sync("node -v", timeout=5)
|
||||
version = out.strip() if out else ""
|
||||
if err and "not found" in err.lower():
|
||||
return {"version": None, "error": "Node.js not installed"}
|
||||
return {"version": version or None}
|
||||
|
||||
|
||||
@router.post("/{proc_id}/start")
|
||||
async def node_start(
|
||||
proc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Start PM2 process"""
|
||||
if not re.match(r"^\d+$", proc_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid process ID")
|
||||
out, err = exec_shell_sync(f"pm2 start {proc_id}", timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Started"}
|
||||
|
||||
|
||||
@router.post("/{proc_id}/stop")
|
||||
async def node_stop(
|
||||
proc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Stop PM2 process"""
|
||||
if not re.match(r"^\d+$", proc_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid process ID")
|
||||
out, err = exec_shell_sync(f"pm2 stop {proc_id}", timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Stopped"}
|
||||
|
||||
|
||||
@router.post("/{proc_id}/restart")
|
||||
async def node_restart(
|
||||
proc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Restart PM2 process"""
|
||||
if not re.match(r"^\d+$", proc_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid process ID")
|
||||
out, err = exec_shell_sync(f"pm2 restart {proc_id}", timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Restarted"}
|
||||
|
||||
|
||||
@router.delete("/{proc_id}")
|
||||
async def node_delete(
|
||||
proc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete PM2 process"""
|
||||
if not re.match(r"^\d+$", proc_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid process ID")
|
||||
out, err = exec_shell_sync(f"pm2 delete {proc_id}", timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Deleted"}
|
||||
127
YakPanel-server/backend/app/api/plugin.py
Normal file
127
YakPanel-server/backend/app/api/plugin.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""YakPanel - Plugin / Extensions API"""
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.plugin import CustomPlugin
|
||||
|
||||
router = APIRouter(prefix="/plugin", tags=["plugin"])
|
||||
|
||||
# Built-in extensions (features) - always enabled
|
||||
BUILTIN_PLUGINS = [
|
||||
{"id": "backup", "name": "Backup", "version": "1.0", "desc": "Site and database backup/restore", "enabled": True, "builtin": True},
|
||||
{"id": "ssl", "name": "SSL/ACME", "version": "1.0", "desc": "Let's Encrypt certificates", "enabled": True, "builtin": True},
|
||||
{"id": "docker", "name": "Docker", "version": "1.0", "desc": "Container management", "enabled": True, "builtin": True},
|
||||
{"id": "node", "name": "Node.js", "version": "1.0", "desc": "PM2 process manager", "enabled": True, "builtin": True},
|
||||
{"id": "services", "name": "Services", "version": "1.0", "desc": "System service control", "enabled": True, "builtin": True},
|
||||
{"id": "logs", "name": "Logs", "version": "1.0", "desc": "Log file viewer", "enabled": True, "builtin": True},
|
||||
{"id": "terminal", "name": "Terminal", "version": "1.0", "desc": "Web terminal", "enabled": True, "builtin": True},
|
||||
{"id": "monitor", "name": "Monitor", "version": "1.0", "desc": "System monitoring", "enabled": True, "builtin": True},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def plugin_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List built-in + custom plugins"""
|
||||
result = await db.execute(select(CustomPlugin).order_by(CustomPlugin.id))
|
||||
custom = result.scalars().all()
|
||||
builtin_ids = {p["id"] for p in BUILTIN_PLUGINS}
|
||||
plugins = list(BUILTIN_PLUGINS)
|
||||
for c in custom:
|
||||
plugins.append({
|
||||
"id": c.plugin_id,
|
||||
"name": c.name,
|
||||
"version": c.version,
|
||||
"desc": c.desc,
|
||||
"enabled": c.enabled,
|
||||
"builtin": False,
|
||||
"db_id": c.id,
|
||||
})
|
||||
return {"plugins": plugins}
|
||||
|
||||
|
||||
class AddPluginRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.post("/add-from-url")
|
||||
async def plugin_add_from_url(
|
||||
body: AddPluginRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a third-party plugin from a JSON manifest URL"""
|
||||
url = (body.url or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="URL required")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="Only http/https URLs allowed")
|
||||
if not parsed.netloc or parsed.netloc.startswith("127.") or parsed.netloc == "localhost":
|
||||
raise HTTPException(status_code=400, detail="Invalid URL")
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "YakPanel/1.0"})
|
||||
with urlopen(req, timeout=10) as r:
|
||||
data = r.read(64 * 1024).decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch: {str(e)[:100]}")
|
||||
try:
|
||||
manifest = json.loads(data)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
|
||||
pid = (manifest.get("id") or "").strip()
|
||||
name = (manifest.get("name") or "").strip()
|
||||
if not pid or not name:
|
||||
raise HTTPException(status_code=400, detail="Manifest must have 'id' and 'name'")
|
||||
if not re.match(r"^[a-z0-9_-]+$", pid):
|
||||
raise HTTPException(status_code=400, detail="Plugin id must be alphanumeric, underscore, hyphen only")
|
||||
version = (manifest.get("version") or "1.0").strip()[:32]
|
||||
desc = (manifest.get("desc") or "").strip()[:512]
|
||||
# Check builtin conflict
|
||||
if any(p["id"] == pid for p in BUILTIN_PLUGINS):
|
||||
raise HTTPException(status_code=400, detail="Plugin id conflicts with built-in")
|
||||
# Check existing custom
|
||||
r = await db.execute(select(CustomPlugin).where(CustomPlugin.plugin_id == pid))
|
||||
if r.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Plugin already installed")
|
||||
cp = CustomPlugin(
|
||||
plugin_id=pid,
|
||||
name=name,
|
||||
version=version,
|
||||
desc=desc,
|
||||
source_url=url[:512],
|
||||
enabled=True,
|
||||
)
|
||||
db.add(cp)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Plugin added", "id": cp.plugin_id}
|
||||
|
||||
|
||||
@router.delete("/{plugin_id}")
|
||||
async def plugin_delete(
|
||||
plugin_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove a custom plugin (built-in plugins cannot be removed)"""
|
||||
if any(p["id"] == plugin_id for p in BUILTIN_PLUGINS):
|
||||
raise HTTPException(status_code=400, detail="Cannot remove built-in plugin")
|
||||
result = await db.execute(select(CustomPlugin).where(CustomPlugin.plugin_id == plugin_id))
|
||||
cp = result.scalar_one_or_none()
|
||||
if not cp:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
await db.delete(cp)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Plugin removed"}
|
||||
310
YakPanel-server/backend/app/api/public_installer.py
Normal file
310
YakPanel-server/backend/app/api/public_installer.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""Optional remote SSH installer — disabled by default (ENABLE_REMOTE_INSTALLER)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Annotated, Any, Literal, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import asyncssh
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/public-install", tags=["public-install"])
|
||||
|
||||
_jobs: dict[str, dict[str, Any]] = {}
|
||||
_rate_buckets: dict[str, list[float]] = defaultdict(list)
|
||||
_jobs_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _safe_host(host: str) -> str:
|
||||
h = host.strip()
|
||||
if not h or len(h) > 253:
|
||||
raise ValueError("invalid host")
|
||||
if re.search(r"[\s;|&$`\\'\"\n<>()]", h):
|
||||
raise ValueError("invalid host characters")
|
||||
try:
|
||||
ipaddress.ip_address(h)
|
||||
return h
|
||||
except ValueError:
|
||||
pass
|
||||
if not re.match(r"^[a-zA-Z0-9.\-]+$", h):
|
||||
raise ValueError("invalid hostname")
|
||||
return h
|
||||
|
||||
|
||||
def _validate_install_url(url: str) -> str:
|
||||
p = urlparse(url.strip())
|
||||
if p.scheme != "https":
|
||||
raise ValueError("install_url must use https")
|
||||
if not p.netloc or p.username is not None or p.password is not None:
|
||||
raise ValueError("invalid install URL")
|
||||
return url.strip()
|
||||
|
||||
|
||||
async def _target_ip_allowed(host: str) -> bool:
|
||||
settings = get_settings()
|
||||
raw = settings.remote_install_allowed_target_cidrs.strip()
|
||||
if not raw:
|
||||
return True
|
||||
cidrs: list = []
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
cidrs.append(ipaddress.ip_network(part, strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
if not cidrs:
|
||||
return True
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def resolve() -> list[str]:
|
||||
out: list[str] = []
|
||||
try:
|
||||
for fam, _ty, _pr, _cn, sa in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM):
|
||||
out.append(sa[0])
|
||||
except socket.gaierror:
|
||||
return []
|
||||
return out
|
||||
|
||||
addrs = await loop.run_in_executor(None, resolve)
|
||||
if not addrs:
|
||||
return False
|
||||
for addr in addrs:
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
if any(ip in net for net in cidrs):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _check_rate_limit(client_ip: str) -> None:
|
||||
settings = get_settings()
|
||||
lim = settings.remote_install_rate_limit_per_ip
|
||||
if lim <= 0:
|
||||
return
|
||||
window_sec = max(1, settings.remote_install_rate_window_minutes) * 60
|
||||
now = time.monotonic()
|
||||
bucket = _rate_buckets[client_ip]
|
||||
bucket[:] = [t for t in bucket if now - t < window_sec]
|
||||
if len(bucket) >= lim:
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded. Try again later.")
|
||||
bucket.append(now)
|
||||
|
||||
|
||||
class AuthKey(BaseModel):
|
||||
type: Literal["key"] = "key"
|
||||
private_key: str = Field(..., min_length=1)
|
||||
passphrase: Optional[str] = None
|
||||
|
||||
|
||||
class AuthPassword(BaseModel):
|
||||
type: Literal["password"] = "password"
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class CreateJobRequest(BaseModel):
|
||||
host: str
|
||||
port: int = Field(default=22, ge=1, le=65535)
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
auth: Annotated[Union[AuthKey, AuthPassword], Field(discriminator="type")]
|
||||
install_url: Optional[str] = None
|
||||
|
||||
@field_validator("host")
|
||||
@classmethod
|
||||
def host_ok(cls, v: str) -> str:
|
||||
return _safe_host(v)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def user_ok(cls, v: str) -> str:
|
||||
u = v.strip()
|
||||
if re.search(r"[\s;|&$`\\'\"\n<>]", u):
|
||||
raise ValueError("invalid username")
|
||||
return u
|
||||
|
||||
@field_validator("install_url")
|
||||
@classmethod
|
||||
def url_ok(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return _validate_install_url(v)
|
||||
|
||||
|
||||
class CreateJobResponse(BaseModel):
|
||||
job_id: str
|
||||
|
||||
|
||||
def _broadcast(job: dict[str, Any], msg: str) -> None:
|
||||
for q in list(job.get("channels", ())):
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def installer_config():
|
||||
s = get_settings()
|
||||
return {
|
||||
"enabled": s.enable_remote_installer,
|
||||
"default_install_url": s.remote_install_default_url,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/jobs", response_model=CreateJobResponse)
|
||||
async def create_job(body: CreateJobRequest, request: Request):
|
||||
settings = get_settings()
|
||||
if not settings.enable_remote_installer:
|
||||
raise HTTPException(status_code=403, detail="Remote installer is disabled")
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
_check_rate_limit(client_ip)
|
||||
|
||||
host = body.host
|
||||
if not await _target_ip_allowed(host):
|
||||
raise HTTPException(status_code=400, detail="Target host is not in allowed CIDR list")
|
||||
|
||||
url = body.install_url or settings.remote_install_default_url
|
||||
try:
|
||||
url = _validate_install_url(url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
channels: set[asyncio.Queue] = set()
|
||||
|
||||
inner = f"curl -fsSL {shlex.quote(url)} | bash"
|
||||
if body.username == "root":
|
||||
remote_cmd = f"bash -lc {shlex.quote(inner)}"
|
||||
else:
|
||||
remote_cmd = f"sudo -n bash -lc {shlex.quote(inner)}"
|
||||
|
||||
auth_payload = body.auth
|
||||
|
||||
async def runner() -> None:
|
||||
async with _jobs_lock:
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
exit_code: Optional[int] = None
|
||||
|
||||
def broadcast(msg: str) -> None:
|
||||
_broadcast(job, msg)
|
||||
|
||||
try:
|
||||
connect_kw: dict[str, Any] = {
|
||||
"host": host,
|
||||
"port": body.port,
|
||||
"username": body.username,
|
||||
"known_hosts": None,
|
||||
"connect_timeout": 30,
|
||||
}
|
||||
if auth_payload.type == "key":
|
||||
try:
|
||||
key = asyncssh.import_private_key(
|
||||
auth_payload.private_key.encode(),
|
||||
passphrase=auth_payload.passphrase or None,
|
||||
)
|
||||
except Exception:
|
||||
broadcast(json.dumps({"type": "line", "text": "Invalid private key or passphrase"}))
|
||||
broadcast(json.dumps({"type": "done", "exit_code": -1}))
|
||||
return
|
||||
connect_kw["client_keys"] = [key]
|
||||
else:
|
||||
connect_kw["password"] = auth_payload.password
|
||||
|
||||
async with asyncssh.connect(**connect_kw) as conn:
|
||||
async with conn.create_process(remote_cmd) as proc:
|
||||
|
||||
async def pump(stream: Any, is_err: bool) -> None:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode(errors="replace").rstrip("\n\r")
|
||||
prefix = "[stderr] " if is_err else ""
|
||||
broadcast(json.dumps({"type": "line", "text": prefix + text}))
|
||||
|
||||
await asyncio.gather(
|
||||
pump(proc.stdout, False),
|
||||
pump(proc.stderr, True),
|
||||
)
|
||||
await proc.wait()
|
||||
exit_code = proc.exit_status
|
||||
except asyncssh.Error as e:
|
||||
msg = str(e).split("\n")[0][:240]
|
||||
broadcast(json.dumps({"type": "line", "text": "SSH error: " + msg}))
|
||||
except OSError as e:
|
||||
broadcast(json.dumps({"type": "line", "text": "Connection error: " + str(e)[:200]}))
|
||||
except Exception:
|
||||
broadcast(json.dumps({"type": "line", "text": "Unexpected installer error"}))
|
||||
|
||||
broadcast(json.dumps({"type": "done", "exit_code": exit_code if exit_code is not None else -1}))
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _purge() -> None:
|
||||
_jobs.pop(job_id, None)
|
||||
|
||||
loop.call_later(900, _purge)
|
||||
|
||||
async with _jobs_lock:
|
||||
_jobs[job_id] = {"channels": channels}
|
||||
task = asyncio.create_task(runner())
|
||||
_jobs[job_id]["task"] = task
|
||||
|
||||
return CreateJobResponse(job_id=job_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/{job_id}")
|
||||
async def job_ws(websocket: WebSocket, job_id: str):
|
||||
settings = get_settings()
|
||||
if not settings.enable_remote_installer:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
async with _jobs_lock:
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
await websocket.send_text(json.dumps({"type": "line", "text": "Unknown or expired job_id"}))
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=500)
|
||||
job["channels"].add(q)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(q.get(), timeout=7200.0)
|
||||
except asyncio.TimeoutError:
|
||||
await websocket.send_text(json.dumps({"type": "line", "text": "… idle timeout"}))
|
||||
break
|
||||
await websocket.send_text(msg)
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
if data.get("type") == "done":
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
finally:
|
||||
job["channels"].discard(q)
|
||||
await websocket.close()
|
||||
101
YakPanel-server/backend/app/api/service.py
Normal file
101
YakPanel-server/backend/app/api/service.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""YakPanel - System services (systemctl)"""
|
||||
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="/service", tags=["service"])
|
||||
|
||||
# Common services (name -> systemd unit)
|
||||
SERVICES = [
|
||||
{"id": "nginx", "name": "Nginx", "unit": "nginx"},
|
||||
{"id": "mysql", "name": "MySQL", "unit": "mysql"},
|
||||
{"id": "mariadb", "name": "MariaDB", "unit": "mariadb"},
|
||||
{"id": "php-fpm", "name": "PHP-FPM", "unit": "php*-fpm"},
|
||||
{"id": "redis", "name": "Redis", "unit": "redis-server"},
|
||||
{"id": "pure-ftpd", "name": "Pure-FTPd", "unit": "pure-ftpd"},
|
||||
]
|
||||
|
||||
|
||||
def _get_unit(unit_pattern: str) -> str:
|
||||
"""Resolve unit pattern (e.g. php*-fpm) to actual unit name."""
|
||||
if "*" not in unit_pattern:
|
||||
return unit_pattern
|
||||
out, _ = exec_shell_sync("systemctl list-unit-files --type=service --no-legend 2>/dev/null | grep -E 'php[0-9.-]+-fpm' | head -1", timeout=5)
|
||||
if out:
|
||||
return out.split()[0]
|
||||
return "php8.2-fpm" # fallback
|
||||
|
||||
|
||||
def _service_status(unit: str) -> str:
|
||||
"""Get service status: active, inactive, failed, not-found."""
|
||||
resolved = _get_unit(unit)
|
||||
out, err = exec_shell_sync(f"systemctl is-active {resolved} 2>/dev/null", timeout=5)
|
||||
status = out.strip() if out else "inactive"
|
||||
if err or status not in ("active", "inactive", "failed", "activating"):
|
||||
return "inactive" if status else "not-found"
|
||||
return status
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def service_list(current_user: User = Depends(get_current_user)):
|
||||
"""List services with status"""
|
||||
result = []
|
||||
for s in SERVICES:
|
||||
unit = _get_unit(s["unit"])
|
||||
status = _service_status(s["unit"])
|
||||
result.append({
|
||||
**s,
|
||||
"unit": unit,
|
||||
"status": status,
|
||||
})
|
||||
return {"services": result}
|
||||
|
||||
|
||||
@router.post("/{service_id}/start")
|
||||
async def service_start(
|
||||
service_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Start service"""
|
||||
s = next((x for x in SERVICES if x["id"] == service_id), None)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
unit = _get_unit(s["unit"])
|
||||
out, err = exec_shell_sync(f"systemctl start {unit}", timeout=30)
|
||||
if err and "Failed" in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Started"}
|
||||
|
||||
|
||||
@router.post("/{service_id}/stop")
|
||||
async def service_stop(
|
||||
service_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Stop service"""
|
||||
s = next((x for x in SERVICES if x["id"] == service_id), None)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
unit = _get_unit(s["unit"])
|
||||
out, err = exec_shell_sync(f"systemctl stop {unit}", timeout=30)
|
||||
if err and "Failed" in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Stopped"}
|
||||
|
||||
|
||||
@router.post("/{service_id}/restart")
|
||||
async def service_restart(
|
||||
service_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Restart service"""
|
||||
s = next((x for x in SERVICES if x["id"] == service_id), None)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
unit = _get_unit(s["unit"])
|
||||
out, err = exec_shell_sync(f"systemctl restart {unit}", timeout=30)
|
||||
if err and "Failed" in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Restarted"}
|
||||
364
YakPanel-server/backend/app/api/site.py
Normal file
364
YakPanel-server/backend/app/api/site.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""YakPanel - Site API"""
|
||||
import os
|
||||
import tarfile
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.notification import send_email
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.site import Site
|
||||
from app.models.redirect import SiteRedirect
|
||||
from app.services.site_service import create_site, list_sites, delete_site, get_site_with_domains, update_site, set_site_status, regenerate_site_vhost
|
||||
|
||||
router = APIRouter(prefix="/site", tags=["site"])
|
||||
|
||||
|
||||
class CreateSiteRequest(BaseModel):
|
||||
name: str
|
||||
path: str | None = None
|
||||
domains: list[str]
|
||||
project_type: str = "PHP"
|
||||
ps: str = ""
|
||||
php_version: str = "74"
|
||||
force_https: bool = False
|
||||
|
||||
|
||||
class UpdateSiteRequest(BaseModel):
|
||||
path: str | None = None
|
||||
domains: list[str] | None = None
|
||||
ps: str | None = None
|
||||
php_version: str | None = None
|
||||
force_https: bool | None = None
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def site_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all sites"""
|
||||
return await list_sites(db)
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def site_create(
|
||||
body: CreateSiteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new site"""
|
||||
cfg = get_runtime_config()
|
||||
path = body.path or os.path.join(cfg["www_root"], body.name)
|
||||
result = await create_site(
|
||||
db,
|
||||
name=body.name,
|
||||
path=path,
|
||||
domains=body.domains,
|
||||
project_type=body.project_type,
|
||||
ps=body.ps,
|
||||
php_version=body.php_version or "74",
|
||||
force_https=1 if body.force_https else 0,
|
||||
)
|
||||
if not result["status"]:
|
||||
raise HTTPException(status_code=400, detail=result["msg"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{site_id}")
|
||||
async def site_get(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get site with domains for editing"""
|
||||
data = await get_site_with_domains(db, site_id)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
return data
|
||||
|
||||
|
||||
@router.put("/{site_id}")
|
||||
async def site_update(
|
||||
site_id: int,
|
||||
body: UpdateSiteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update site domains, path, or note"""
|
||||
result = await update_site(
|
||||
db, site_id,
|
||||
path=body.path,
|
||||
domains=body.domains,
|
||||
ps=body.ps,
|
||||
php_version=body.php_version,
|
||||
force_https=None if body.force_https is None else (1 if body.force_https else 0),
|
||||
)
|
||||
if not result["status"]:
|
||||
raise HTTPException(status_code=400, detail=result["msg"])
|
||||
return result
|
||||
|
||||
|
||||
class SiteStatusRequest(BaseModel):
|
||||
status: int
|
||||
|
||||
|
||||
@router.post("/{site_id}/status")
|
||||
async def site_set_status(
|
||||
site_id: int,
|
||||
body: SiteStatusRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Enable (1) or disable (0) site"""
|
||||
if body.status not in (0, 1):
|
||||
raise HTTPException(status_code=400, detail="Status must be 0 or 1")
|
||||
result = await set_site_status(db, site_id, body.status)
|
||||
if not result["status"]:
|
||||
raise HTTPException(status_code=404, detail=result["msg"])
|
||||
return result
|
||||
|
||||
|
||||
class AddRedirectRequest(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
code: int = 301
|
||||
|
||||
|
||||
class GitCloneRequest(BaseModel):
|
||||
url: str
|
||||
branch: str = "main"
|
||||
|
||||
|
||||
@router.post("/{site_id}/git/clone")
|
||||
async def site_git_clone(
|
||||
site_id: int,
|
||||
body: GitCloneRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Clone Git repo into site path (git clone -b branch url .)"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
url = (body.url or "").strip()
|
||||
if not url or " " in url or ";" in url or "|" in url:
|
||||
raise HTTPException(status_code=400, detail="Invalid Git URL")
|
||||
path = site.path
|
||||
if not os.path.isdir(path):
|
||||
raise HTTPException(status_code=400, detail="Site path does not exist")
|
||||
branch = body.branch or "main"
|
||||
if os.path.isdir(os.path.join(path, ".git")):
|
||||
raise HTTPException(status_code=400, detail="Already a Git repo; use Pull instead")
|
||||
out, err = exec_shell_sync(
|
||||
f"cd {path} && git init && git remote add origin {url} && git fetch origin {branch} && git checkout -b {branch} origin/{branch}",
|
||||
timeout=120,
|
||||
)
|
||||
if err and "error" in err.lower() and "fatal" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Cloned"}
|
||||
|
||||
|
||||
@router.post("/{site_id}/git/pull")
|
||||
async def site_git_pull(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Git pull in site path"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
path = site.path
|
||||
if not os.path.isdir(os.path.join(path, ".git")):
|
||||
raise HTTPException(status_code=400, detail="Not a Git repository")
|
||||
out, err = exec_shell_sync(f"cd {path} && git pull", timeout=60)
|
||||
if err and "error" in err.lower() and "fatal" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Pulled", "output": out}
|
||||
|
||||
|
||||
@router.get("/{site_id}/redirects")
|
||||
async def site_redirects_list(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List redirects for a site"""
|
||||
result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site_id).order_by(SiteRedirect.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "source": r.source, "target": r.target, "code": r.code} for r in rows]
|
||||
|
||||
|
||||
@router.post("/{site_id}/redirects")
|
||||
async def site_redirect_add(
|
||||
site_id: int,
|
||||
body: AddRedirectRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add redirect for a site"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if not body.source or not body.target:
|
||||
raise HTTPException(status_code=400, detail="Source and target required")
|
||||
if body.code not in (301, 302):
|
||||
raise HTTPException(status_code=400, detail="Code must be 301 or 302")
|
||||
r = SiteRedirect(site_id=site_id, source=body.source.strip(), target=body.target.strip(), code=body.code)
|
||||
db.add(r)
|
||||
await db.commit()
|
||||
regen = await regenerate_site_vhost(db, site_id)
|
||||
if not regen["status"]:
|
||||
pass # redirect saved, vhost may need manual reload
|
||||
return {"status": True, "msg": "Redirect added", "id": r.id}
|
||||
|
||||
|
||||
@router.delete("/{site_id}/redirects/{redirect_id}")
|
||||
async def site_redirect_delete(
|
||||
site_id: int,
|
||||
redirect_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a redirect"""
|
||||
result = await db.execute(select(SiteRedirect).where(SiteRedirect.id == redirect_id, SiteRedirect.site_id == site_id))
|
||||
r = result.scalar_one_or_none()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Redirect not found")
|
||||
await db.delete(r)
|
||||
await db.commit()
|
||||
await regenerate_site_vhost(db, site_id)
|
||||
return {"status": True, "msg": "Redirect deleted"}
|
||||
|
||||
|
||||
@router.delete("/{site_id}")
|
||||
async def site_delete(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a site"""
|
||||
result = await delete_site(db, site_id)
|
||||
if not result["status"]:
|
||||
raise HTTPException(status_code=404, detail=result["msg"])
|
||||
return result
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
filename: str
|
||||
|
||||
|
||||
@router.post("/{site_id}/backup")
|
||||
async def site_backup(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create tar.gz backup of site directory"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if not os.path.isdir(site.path):
|
||||
raise HTTPException(status_code=400, detail="Site path does not exist")
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = cfg["backup_path"]
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{site.name}_{ts}.tar.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
try:
|
||||
with tarfile.open(dest, "w:gz") as tf:
|
||||
tf.add(site.path, arcname=os.path.basename(site.path))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
# Send notification if email configured
|
||||
send_email(
|
||||
subject=f"YakPanel - Site backup: {site.name}",
|
||||
body=f"Backup completed: {filename}\nSite: {site.name}\nPath: {site.path}",
|
||||
)
|
||||
return {"status": True, "msg": "Backup created", "filename": filename}
|
||||
|
||||
|
||||
@router.get("/{site_id}/backups")
|
||||
async def site_backups_list(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List backups for a site"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = cfg["backup_path"]
|
||||
if not os.path.isdir(backup_dir):
|
||||
return {"backups": []}
|
||||
prefix = f"{site.name}_"
|
||||
backups = []
|
||||
for f in os.listdir(backup_dir):
|
||||
if f.startswith(prefix) and f.endswith(".tar.gz"):
|
||||
p = os.path.join(backup_dir, f)
|
||||
backups.append({"filename": f, "size": os.path.getsize(p) if os.path.isfile(p) else 0})
|
||||
backups.sort(key=lambda x: x["filename"], reverse=True)
|
||||
return {"backups": backups}
|
||||
|
||||
|
||||
@router.get("/{site_id}/backups/download")
|
||||
async def site_backup_download(
|
||||
site_id: int,
|
||||
file: str = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download backup file"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{site.name}_") or not file.endswith(".tar.gz"):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
cfg = get_runtime_config()
|
||||
path = os.path.join(cfg["backup_path"], file)
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
return FileResponse(path, filename=file)
|
||||
|
||||
|
||||
@router.post("/{site_id}/restore")
|
||||
async def site_restore(
|
||||
site_id: int,
|
||||
body: RestoreRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Restore site from backup"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
file = body.filename
|
||||
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{site.name}_") or not file.endswith(".tar.gz"):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
cfg = get_runtime_config()
|
||||
backup_path = os.path.join(cfg["backup_path"], file)
|
||||
if not os.path.isfile(backup_path):
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
parent = os.path.dirname(site.path)
|
||||
try:
|
||||
with tarfile.open(backup_path, "r:gz") as tf:
|
||||
tf.extractall(parent)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"status": True, "msg": "Restored"}
|
||||
81
YakPanel-server/backend/app/api/soft.py
Normal file
81
YakPanel-server/backend/app/api/soft.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""YakPanel - App Store / Software API"""
|
||||
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"])
|
||||
|
||||
# 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"},
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
if out.strip():
|
||||
# Parse version from dpkg output: ii pkg version ...
|
||||
parts = out.split()
|
||||
if len(parts) >= 3:
|
||||
return True, parts[2]
|
||||
return False, ""
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def soft_list(current_user: User = Depends(get_current_user)):
|
||||
"""List software with install status"""
|
||||
result = []
|
||||
for s in SOFTWARE_LIST:
|
||||
installed, version = _check_installed(s["pkg"])
|
||||
result.append({
|
||||
**s,
|
||||
"installed": installed,
|
||||
"version": version if installed else "",
|
||||
})
|
||||
return {"software": result}
|
||||
|
||||
|
||||
@router.post("/install/{pkg_id}")
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
out, err = exec_shell_sync(f"apt-get update && apt-get install -y {pkg}", timeout=300)
|
||||
if err and "error" in err.lower() and "E: " in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
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 apt"""
|
||||
pkg = next((s["pkg"] for s in SOFTWARE_LIST if s["id"] == pkg_id), None)
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
out, err = exec_shell_sync(f"apt-get remove -y {pkg}", timeout=120)
|
||||
if err and "error" in err.lower() and "E: " in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Uninstalled"}
|
||||
85
YakPanel-server/backend/app/api/ssl.py
Normal file
85
YakPanel-server/backend/app/api/ssl.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""YakPanel - SSL/Domains API - Let's Encrypt via certbot"""
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.site import Site, Domain
|
||||
|
||||
router = APIRouter(prefix="/ssl", tags=["ssl"])
|
||||
|
||||
|
||||
@router.get("/domains")
|
||||
async def ssl_domains(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all domains from sites with site path for certbot webroot"""
|
||||
result = await db.execute(
|
||||
select(Domain, Site).join(Site, Domain.pid == Site.id).order_by(Domain.name)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"port": d.port,
|
||||
"site_id": s.id,
|
||||
"site_name": s.name,
|
||||
"site_path": s.path,
|
||||
}
|
||||
for d, s in rows
|
||||
]
|
||||
|
||||
|
||||
class RequestCertRequest(BaseModel):
|
||||
domain: str
|
||||
webroot: str
|
||||
email: str
|
||||
|
||||
|
||||
@router.post("/request")
|
||||
async def ssl_request_cert(
|
||||
body: RequestCertRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Request Let's Encrypt certificate via certbot (webroot challenge)"""
|
||||
if not body.domain or not body.webroot or not body.email:
|
||||
raise HTTPException(status_code=400, detail="domain, webroot and email required")
|
||||
if ".." in body.domain or ".." in body.webroot:
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
cfg = get_runtime_config()
|
||||
allowed = [os.path.abspath(cfg["www_root"]), os.path.abspath(cfg["setup_path"])]
|
||||
webroot_abs = os.path.abspath(body.webroot)
|
||||
if not any(webroot_abs.startswith(a + os.sep) or webroot_abs == a for a in allowed):
|
||||
raise HTTPException(status_code=400, detail="Webroot must be under www_root or setup_path")
|
||||
cmd = (
|
||||
f'certbot certonly --webroot -w "{body.webroot}" -d "{body.domain}" '
|
||||
f'--non-interactive --agree-tos --email "{body.email}"'
|
||||
)
|
||||
out, err = exec_shell_sync(cmd, timeout=120)
|
||||
if err and "error" in err.lower() and "successfully" not in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Certificate requested", "output": out}
|
||||
|
||||
|
||||
@router.get("/certificates")
|
||||
async def ssl_list_certificates(current_user: User = Depends(get_current_user)):
|
||||
"""List existing Let's Encrypt certificates"""
|
||||
live_dir = "/etc/letsencrypt/live"
|
||||
if not os.path.isdir(live_dir):
|
||||
return {"certificates": []}
|
||||
certs = []
|
||||
for name in os.listdir(live_dir):
|
||||
if name.startswith("."):
|
||||
continue
|
||||
path = os.path.join(live_dir, name)
|
||||
if os.path.isdir(path) and os.path.isfile(os.path.join(path, "fullchain.pem")):
|
||||
certs.append({"name": name, "path": path})
|
||||
return {"certificates": sorted(certs, key=lambda x: x["name"])}
|
||||
68
YakPanel-server/backend/app/api/terminal.py
Normal file
68
YakPanel-server/backend/app/api/terminal.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""YakPanel - Web Terminal API (WebSocket)"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
router = APIRouter(prefix="/terminal", tags=["terminal"])
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def terminal_websocket(websocket: WebSocket):
|
||||
"""WebSocket terminal - spawns shell and streams I/O"""
|
||||
await websocket.accept()
|
||||
|
||||
token = websocket.query_params.get("token")
|
||||
if token:
|
||||
from app.core.security import decode_token
|
||||
if not decode_token(token):
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
if sys.platform == "win32":
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
"cmd.exe",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
"/bin/bash",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
env={**os.environ, "TERM": "xterm-256color"},
|
||||
)
|
||||
|
||||
async def read_stdout():
|
||||
try:
|
||||
while proc.returncode is None and proc.stdout:
|
||||
data = await proc.stdout.read(4096)
|
||||
if data:
|
||||
await websocket.send_text(data.decode("utf-8", errors="replace"))
|
||||
except (WebSocketDisconnect, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
async def read_websocket():
|
||||
try:
|
||||
while True:
|
||||
msg = await websocket.receive()
|
||||
data = msg.get("text") or (msg.get("bytes") or b"").decode("utf-8", errors="replace")
|
||||
if data and proc.stdin and not proc.stdin.is_closing():
|
||||
proc.stdin.write(data.encode("utf-8"))
|
||||
await proc.stdin.drain()
|
||||
except (WebSocketDisconnect, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
await asyncio.gather(read_stdout(), read_websocket())
|
||||
109
YakPanel-server/backend/app/api/user.py
Normal file
109
YakPanel-server/backend/app/api/user.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""YakPanel - User management API (admin only)"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_password_hash
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/user", tags=["user"])
|
||||
|
||||
|
||||
def require_superuser(current_user: User):
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: str = ""
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def user_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all users (admin only)"""
|
||||
require_superuser(current_user)
|
||||
result = await db.execute(select(User).order_by(User.id))
|
||||
rows = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"username": r.username,
|
||||
"email": r.email or "",
|
||||
"is_active": r.is_active,
|
||||
"is_superuser": r.is_superuser,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def user_create(
|
||||
body: CreateUserRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new user (admin only)"""
|
||||
require_superuser(current_user)
|
||||
if not body.username or len(body.username) < 2:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
|
||||
if not body.password or len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
result = await db.execute(select(User).where(User.username == body.username))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
user = User(
|
||||
username=body.username,
|
||||
password=get_password_hash(body.password),
|
||||
email=body.email.strip() or None,
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "User created", "id": user.id}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def user_delete(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a user (admin only). Cannot delete self."""
|
||||
require_superuser(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "User deleted"}
|
||||
|
||||
|
||||
@router.put("/{user_id}/toggle-active")
|
||||
async def user_toggle_active(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Toggle user active status (admin only). Cannot deactivate self."""
|
||||
require_superuser(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user.is_active = not user.is_active
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Updated", "is_active": user.is_active}
|
||||
1
YakPanel-server/backend/app/core/__init__.py
Normal file
1
YakPanel-server/backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# YakPanel - Core module
|
||||
88
YakPanel-server/backend/app/core/config.py
Normal file
88
YakPanel-server/backend/app/core/config.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""YakPanel - Configuration"""
|
||||
import os
|
||||
from typing import Any
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
# Runtime config loaded from DB on startup (overrides Settings)
|
||||
_runtime_config: dict[str, Any] = {}
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
app_name: str = "YakPanel"
|
||||
app_version: str = "1.0.0"
|
||||
debug: bool = False
|
||||
|
||||
# Paths (Ubuntu/Debian default)
|
||||
panel_path: str = "/www/server/YakPanel-server"
|
||||
setup_path: str = "/www/server"
|
||||
www_root: str = "/www/wwwroot"
|
||||
www_logs: str = "/www/wwwlogs"
|
||||
vhost_path: str = "/www/server/panel/vhost"
|
||||
|
||||
# Database (use absolute path for SQLite)
|
||||
database_url: str = "sqlite+aiosqlite:///./data/default.db"
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Auth
|
||||
secret_key: str = "YakPanel-server-secret-change-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 # 24 hours
|
||||
|
||||
# Panel
|
||||
panel_port: int = 8888
|
||||
webserver_type: str = "nginx" # nginx, apache, openlitespeed
|
||||
|
||||
# CORS (comma-separated origins, e.g. https://panel.example.com)
|
||||
cors_extra_origins: str = ""
|
||||
|
||||
# Remote SSH installer (disabled by default — high risk; see docs)
|
||||
enable_remote_installer: bool = False
|
||||
remote_install_default_url: str = "https://www.yakpanel.com/YakPanel-server/install.sh"
|
||||
remote_install_rate_limit_per_ip: int = 10
|
||||
remote_install_rate_window_minutes: int = 60
|
||||
# Comma-separated CIDRs; empty = no restriction (e.g. "10.0.0.0/8,192.168.0.0/16")
|
||||
remote_install_allowed_target_cidrs: str = ""
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def get_runtime_config() -> dict[str, Any]:
|
||||
"""Get effective panel config (Settings + DB overrides)."""
|
||||
s = get_settings()
|
||||
base = {
|
||||
"panel_port": s.panel_port,
|
||||
"www_root": s.www_root,
|
||||
"setup_path": s.setup_path,
|
||||
"www_logs": s.www_logs,
|
||||
"vhost_path": s.vhost_path,
|
||||
"webserver_type": s.webserver_type,
|
||||
"mysql_root": "",
|
||||
}
|
||||
for k, v in _runtime_config.items():
|
||||
if k in base:
|
||||
if k == "panel_port":
|
||||
try:
|
||||
base[k] = int(v)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
base[k] = v
|
||||
base["backup_path"] = os.path.join(base["setup_path"], "backup")
|
||||
return base
|
||||
|
||||
|
||||
def set_runtime_config_overrides(overrides: dict[str, str]) -> None:
|
||||
"""Set runtime config from DB (called on startup)."""
|
||||
global _runtime_config
|
||||
_runtime_config = dict(overrides)
|
||||
97
YakPanel-server/backend/app/core/database.py
Normal file
97
YakPanel-server/backend/app/core/database.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""YakPanel - Database configuration"""
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Ensure data directory exists for SQLite
|
||||
if "sqlite" in settings.database_url:
|
||||
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
data_dir = os.path.join(backend_dir, "data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""SQLAlchemy declarative base"""
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""Dependency for async database sessions"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
def _run_migrations(conn):
|
||||
"""Add new columns to existing tables (SQLite)."""
|
||||
import sqlalchemy
|
||||
try:
|
||||
r = conn.execute(sqlalchemy.text("PRAGMA table_info(sites)"))
|
||||
cols = [row[1] for row in r.fetchall()]
|
||||
if "php_version" not in cols:
|
||||
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN php_version VARCHAR(16) DEFAULT '74'"))
|
||||
if "force_https" not in cols:
|
||||
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN force_https INTEGER DEFAULT 0"))
|
||||
except Exception:
|
||||
pass
|
||||
# Create backup_plans if not exists (create_all handles new installs)
|
||||
try:
|
||||
conn.execute(sqlalchemy.text("""
|
||||
CREATE TABLE IF NOT EXISTS backup_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
plan_type VARCHAR(32) NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
schedule VARCHAR(64) NOT NULL,
|
||||
enabled BOOLEAN DEFAULT 1
|
||||
)
|
||||
"""))
|
||||
except Exception:
|
||||
pass
|
||||
# Create custom_plugins if not exists
|
||||
try:
|
||||
conn.execute(sqlalchemy.text("""
|
||||
CREATE TABLE IF NOT EXISTS custom_plugins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id VARCHAR(64) UNIQUE NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
version VARCHAR(32) DEFAULT '1.0',
|
||||
desc VARCHAR(512) DEFAULT '',
|
||||
source_url VARCHAR(512) DEFAULT '',
|
||||
enabled BOOLEAN DEFAULT 1
|
||||
)
|
||||
"""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables"""
|
||||
import app.models # noqa: F401 - register all models with Base.metadata
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
if "sqlite" in str(engine.url):
|
||||
await conn.run_sync(_run_migrations)
|
||||
41
YakPanel-server/backend/app/core/notification.py
Normal file
41
YakPanel-server/backend/app/core/notification.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""YakPanel - Email notifications"""
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from app.core.config import get_runtime_config
|
||||
|
||||
|
||||
def send_email(subject: str, body: str, to: str | None = None) -> tuple[bool, str]:
|
||||
"""Send email via SMTP. Returns (success, message)."""
|
||||
cfg = get_runtime_config()
|
||||
email_to = to or cfg.get("email_to", "").strip()
|
||||
smtp_server = cfg.get("smtp_server", "").strip()
|
||||
smtp_port = int(cfg.get("smtp_port") or 587)
|
||||
smtp_user = cfg.get("smtp_user", "").strip()
|
||||
smtp_password = cfg.get("smtp_password", "").strip()
|
||||
|
||||
if not email_to:
|
||||
return False, "Email recipient not configured"
|
||||
if not smtp_server:
|
||||
return False, "SMTP server not configured"
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_user or "YakPanel-server@localhost"
|
||||
msg["To"] = email_to
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
with smtplib.SMTP(smtp_server, smtp_port, timeout=15) as server:
|
||||
if smtp_user and smtp_password:
|
||||
server.starttls()
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.sendmail(msg["From"], [email_to], msg.as_string())
|
||||
return True, "Sent"
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
return False, f"SMTP auth failed: {e}"
|
||||
except smtplib.SMTPException as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
38
YakPanel-server/backend/app/core/security.py
Normal file
38
YakPanel-server/backend/app/core/security.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""YakPanel - Security utilities"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create JWT access token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and validate JWT token"""
|
||||
try:
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
except JWTError:
|
||||
return None
|
||||
112
YakPanel-server/backend/app/core/utils.py
Normal file
112
YakPanel-server/backend/app/core/utils.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""YakPanel - Utility functions (ported from legacy panel public module)"""
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import asyncio
|
||||
import subprocess
|
||||
import html
|
||||
from typing import Tuple, Optional
|
||||
|
||||
regex_safe_path = re.compile(r"^[\w\s./\-]*$")
|
||||
|
||||
|
||||
def md5(strings: str | bytes) -> str:
|
||||
"""Generate MD5 hash"""
|
||||
if isinstance(strings, str):
|
||||
strings = strings.encode("utf-8")
|
||||
return hashlib.md5(strings).hexdigest()
|
||||
|
||||
|
||||
def read_file(filename: str, mode: str = "r") -> str | bytes | None:
|
||||
"""Read file contents"""
|
||||
if not os.path.exists(filename):
|
||||
return None
|
||||
try:
|
||||
with open(filename, mode, encoding="utf-8" if "b" not in mode else None) as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
try:
|
||||
with open(filename, mode) as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_file(filename: str, content: str | bytes, mode: str = "w+") -> bool:
|
||||
"""Write content to file"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(filename) or ".", exist_ok=True)
|
||||
with open(filename, mode, encoding="utf-8" if "b" not in mode else None) as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def xss_decode(text: str) -> str:
|
||||
"""Decode XSS-encoded text"""
|
||||
try:
|
||||
cs = {""": '"', """: '"', "'": "'", "'": "'"}
|
||||
for k, v in cs.items():
|
||||
text = text.replace(k, v)
|
||||
return html.unescape(text)
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def path_safe_check(path: str, force: bool = True) -> bool:
|
||||
"""Validate path for security (no traversal, no dangerous chars)"""
|
||||
if len(path) > 256:
|
||||
return False
|
||||
checks = ["..", "./", "\\", "%", "$", "^", "&", "*", "~", '"', "'", ";", "|", "{", "}", "`"]
|
||||
for c in checks:
|
||||
if c in path:
|
||||
return False
|
||||
if force and not regex_safe_path.match(path):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def exec_shell(
|
||||
cmd: str,
|
||||
timeout: Optional[float] = None,
|
||||
cwd: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Execute shell command asynchronously. Returns (stdout, stderr)."""
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout or 300,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return "", "Timed out"
|
||||
out = stdout.decode("utf-8", errors="replace") if stdout else ""
|
||||
err = stderr.decode("utf-8", errors="replace") if stderr else ""
|
||||
return out, err
|
||||
|
||||
|
||||
def exec_shell_sync(cmd: str, timeout: Optional[float] = None, cwd: Optional[str] = None) -> Tuple[str, str]:
|
||||
"""Execute shell command synchronously. Returns (stdout, stderr)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
timeout=timeout or 300,
|
||||
cwd=cwd,
|
||||
)
|
||||
out = result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
|
||||
err = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
|
||||
return out, err
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "Timed out"
|
||||
except Exception as e:
|
||||
return "", str(e)
|
||||
99
YakPanel-server/backend/app/main.py
Normal file
99
YakPanel-server/backend/app/main.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""YakPanel - Main FastAPI application"""
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings, set_runtime_config_overrides
|
||||
from app.core.database import init_db, AsyncSessionLocal
|
||||
from app.models.config import Config
|
||||
from app.api import (
|
||||
auth,
|
||||
backup,
|
||||
dashboard,
|
||||
user,
|
||||
site,
|
||||
ftp,
|
||||
database,
|
||||
files,
|
||||
crontab,
|
||||
firewall,
|
||||
ssl,
|
||||
monitor,
|
||||
docker,
|
||||
plugin,
|
||||
soft,
|
||||
terminal,
|
||||
config,
|
||||
logs,
|
||||
node,
|
||||
service,
|
||||
public_installer,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan - init DB on startup, load config from DB"""
|
||||
await init_db()
|
||||
# Load panel config from DB (persisted settings from Settings page)
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Config))
|
||||
rows = result.scalars().all()
|
||||
overrides = {r.key: r.value for r in rows if r.value is not None}
|
||||
set_runtime_config_overrides(overrides)
|
||||
yield
|
||||
# Cleanup if needed
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
_cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||
_extra = (settings.cors_extra_origins or "").strip()
|
||||
if _extra:
|
||||
_cors_origins.extend([o.strip() for o in _extra.split(",") if o.strip()])
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(backup.router, prefix="/api/v1")
|
||||
app.include_router(dashboard.router, prefix="/api/v1")
|
||||
app.include_router(site.router, prefix="/api/v1")
|
||||
app.include_router(ftp.router, prefix="/api/v1")
|
||||
app.include_router(database.router, prefix="/api/v1")
|
||||
app.include_router(files.router, prefix="/api/v1")
|
||||
app.include_router(crontab.router, prefix="/api/v1")
|
||||
app.include_router(firewall.router, prefix="/api/v1")
|
||||
app.include_router(ssl.router, prefix="/api/v1")
|
||||
app.include_router(monitor.router, prefix="/api/v1")
|
||||
app.include_router(docker.router, prefix="/api/v1")
|
||||
app.include_router(plugin.router, prefix="/api/v1")
|
||||
app.include_router(soft.router, prefix="/api/v1")
|
||||
app.include_router(node.router, prefix="/api/v1")
|
||||
app.include_router(service.router, prefix="/api/v1")
|
||||
app.include_router(terminal.router, prefix="/api/v1")
|
||||
app.include_router(config.router, prefix="/api/v1")
|
||||
app.include_router(user.router, prefix="/api/v1")
|
||||
app.include_router(logs.router, prefix="/api/v1")
|
||||
app.include_router(public_installer.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"app": settings.app_name, "version": settings.app_version}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
13
YakPanel-server/backend/app/models/__init__.py
Normal file
13
YakPanel-server/backend/app/models/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# YakPanel - Models
|
||||
from app.models.user import User
|
||||
from app.models.config import Config
|
||||
from app.models.site import Site, Domain
|
||||
from app.models.redirect import SiteRedirect
|
||||
from app.models.ftp import Ftp
|
||||
from app.models.database import Database
|
||||
from app.models.crontab import Crontab
|
||||
from app.models.firewall import FirewallRule
|
||||
from app.models.backup_plan import BackupPlan
|
||||
from app.models.plugin import CustomPlugin
|
||||
|
||||
__all__ = ["User", "Config", "Site", "Domain", "SiteRedirect", "Ftp", "Database", "Crontab", "FirewallRule", "BackupPlan", "CustomPlugin"]
|
||||
15
YakPanel-server/backend/app/models/backup_plan.py
Normal file
15
YakPanel-server/backend/app/models/backup_plan.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""YakPanel - Backup plan model for scheduled backups"""
|
||||
from sqlalchemy import String, Integer, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BackupPlan(Base):
|
||||
__tablename__ = "backup_plans"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
plan_type: Mapped[str] = mapped_column(String(32), nullable=False) # site | database
|
||||
target_id: Mapped[int] = mapped_column(Integer, nullable=False) # site_id or database_id
|
||||
schedule: Mapped[str] = mapped_column(String(64), nullable=False) # cron expression, e.g. "0 2 * * *" = daily 2am
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
12
YakPanel-server/backend/app/models/config.py
Normal file
12
YakPanel-server/backend/app/models/config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""YakPanel - Config model (panel settings)"""
|
||||
from sqlalchemy import String, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Config(Base):
|
||||
__tablename__ = "config"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=True, default="")
|
||||
16
YakPanel-server/backend/app/models/crontab.py
Normal file
16
YakPanel-server/backend/app/models/crontab.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""YakPanel - Crontab model"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Crontab(Base):
|
||||
__tablename__ = "crontab"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), default="")
|
||||
type: Mapped[str] = mapped_column(String(32), default="shell")
|
||||
execstr: Mapped[str] = mapped_column(Text, default="")
|
||||
schedule: Mapped[str] = mapped_column(String(64), default="")
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
19
YakPanel-server/backend/app/models/database.py
Normal file
19
YakPanel-server/backend/app/models/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""YakPanel - Database model"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Database(Base):
|
||||
__tablename__ = "databases"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
sid: Mapped[int] = mapped_column(Integer, default=0)
|
||||
pid: Mapped[int] = mapped_column(Integer, ForeignKey("sites.id"), default=0)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(255), default="")
|
||||
db_type: Mapped[str] = mapped_column(String(32), default="MySQL")
|
||||
ps: Mapped[str] = mapped_column(String(255), default="")
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
14
YakPanel-server/backend/app/models/firewall.py
Normal file
14
YakPanel-server/backend/app/models/firewall.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""YakPanel - Firewall model"""
|
||||
from sqlalchemy import String, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class FirewallRule(Base):
|
||||
__tablename__ = "firewall"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
port: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
protocol: Mapped[str] = mapped_column(String(16), default="tcp")
|
||||
action: Mapped[str] = mapped_column(String(16), default="accept")
|
||||
ps: Mapped[str] = mapped_column(String(255), default="")
|
||||
17
YakPanel-server/backend/app/models/ftp.py
Normal file
17
YakPanel-server/backend/app/models/ftp.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""YakPanel - FTP model"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Ftp(Base):
|
||||
__tablename__ = "ftps"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
pid: Mapped[int] = mapped_column(Integer, ForeignKey("sites.id"), default=0)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
ps: Mapped[str] = mapped_column(String(255), default="")
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
16
YakPanel-server/backend/app/models/plugin.py
Normal file
16
YakPanel-server/backend/app/models/plugin.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""YakPanel - Custom plugin model (third-party plugins added from URL)"""
|
||||
from sqlalchemy import String, Integer, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class CustomPlugin(Base):
|
||||
__tablename__ = "custom_plugins"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
plugin_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) # unique id from manifest
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
version: Mapped[str] = mapped_column(String(32), default="1.0")
|
||||
desc: Mapped[str] = mapped_column(String(512), default="")
|
||||
source_url: Mapped[str] = mapped_column(String(512), default="") # URL it was installed from
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
14
YakPanel-server/backend/app/models/redirect.py
Normal file
14
YakPanel-server/backend/app/models/redirect.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""YakPanel - Site redirect model"""
|
||||
from sqlalchemy import String, Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SiteRedirect(Base):
|
||||
__tablename__ = "site_redirects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
site_id: Mapped[int] = mapped_column(Integer, ForeignKey("sites.id"), nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(512), nullable=False) # e.g. /old-path or domain.com/old
|
||||
target: Mapped[str] = mapped_column(String(512), nullable=False) # e.g. /new-path or https://...
|
||||
code: Mapped[int] = mapped_column(Integer, default=301) # 301 or 302
|
||||
29
YakPanel-server/backend/app/models/site.py
Normal file
29
YakPanel-server/backend/app/models/site.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""YakPanel - Site and Domain models"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Site(Base):
|
||||
__tablename__ = "sites"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
status: Mapped[int] = mapped_column(Integer, default=1) # 0=stopped, 1=running
|
||||
ps: Mapped[str] = mapped_column(String(255), default="")
|
||||
project_type: Mapped[str] = mapped_column(String(32), default="PHP")
|
||||
php_version: Mapped[str] = mapped_column(String(16), default="74") # 74, 80, 81, 82
|
||||
force_https: Mapped[int] = mapped_column(Integer, default=0) # 0=off, 1=redirect HTTP to HTTPS
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Domain(Base):
|
||||
__tablename__ = "domain"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
pid: Mapped[int] = mapped_column(Integer, ForeignKey("sites.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
port: Mapped[str] = mapped_column(String(16), default="80")
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
18
YakPanel-server/backend/app/models/user.py
Normal file
18
YakPanel-server/backend/app/models/user.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""YakPanel - User model"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(128), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
1
YakPanel-server/backend/app/services/__init__.py
Normal file
1
YakPanel-server/backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# YakPanel - Services
|
||||
49
YakPanel-server/backend/app/services/config_service.py
Normal file
49
YakPanel-server/backend/app/services/config_service.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""YakPanel - Config service (panel settings)"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.config import Config
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
async def get_config_value(db: AsyncSession, key: str) -> str:
|
||||
"""Get config value by key"""
|
||||
result = await db.execute(select(Config).where(Config.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
return row.value if row else ""
|
||||
|
||||
|
||||
async def set_config_value(db: AsyncSession, key: str, value: str) -> None:
|
||||
"""Set config value"""
|
||||
result = await db.execute(select(Config).where(Config.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
db.add(Config(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
|
||||
def get_webserver_type() -> str:
|
||||
"""Get webserver type (nginx, apache, openlitespeed)"""
|
||||
return get_settings().webserver_type
|
||||
|
||||
|
||||
def get_setup_path() -> str:
|
||||
"""Get server setup path"""
|
||||
return get_settings().setup_path
|
||||
|
||||
|
||||
def get_www_root() -> str:
|
||||
"""Get www root path"""
|
||||
return get_settings().www_root
|
||||
|
||||
|
||||
def get_www_logs() -> str:
|
||||
"""Get www logs path"""
|
||||
return get_settings().www_logs
|
||||
|
||||
|
||||
def get_vhost_path() -> str:
|
||||
"""Get vhost config path"""
|
||||
return get_settings().vhost_path
|
||||
411
YakPanel-server/backend/app/services/database_service.py
Normal file
411
YakPanel-server/backend/app/services/database_service.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""YakPanel - Database service (MySQL creation via CLI; PostgreSQL/MongoDB/Redis panel records only)"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from app.core.config import get_runtime_config
|
||||
|
||||
|
||||
def get_mysql_root() -> str | None:
|
||||
"""Get MySQL root password from config (key: mysql_root)"""
|
||||
cfg = get_runtime_config()
|
||||
return cfg.get("mysql_root") or None
|
||||
|
||||
|
||||
def _run_mysql(sql: str, root_pw: str) -> tuple[str, str]:
|
||||
"""Run mysql with SQL. Uses MYSQL_PWD to avoid password in argv."""
|
||||
env = os.environ.copy()
|
||||
env["MYSQL_PWD"] = root_pw
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mysql", "-u", "root", "-e", sql],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env=env,
|
||||
)
|
||||
return r.stdout or "", r.stderr or ""
|
||||
except FileNotFoundError:
|
||||
return "", "mysql command not found"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "Timed out"
|
||||
|
||||
|
||||
def create_mysql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create MySQL database and user via mysql CLI. Returns (success, message)."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured. Set it in Settings."
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters in name: {x}"
|
||||
pw_esc = password.replace("\\", "\\\\").replace("'", "''")
|
||||
steps = [
|
||||
(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;", "create database"),
|
||||
(f"CREATE USER IF NOT EXISTS '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';", "create user"),
|
||||
(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{username}'@'localhost';", "grant"),
|
||||
("FLUSH PRIVILEGES;", "flush"),
|
||||
]
|
||||
for sql, step in steps:
|
||||
out, err = _run_mysql(sql, root_pw)
|
||||
if err and "error" in err.lower() and "already exists" not in err.lower():
|
||||
return False, f"{step}: {err.strip() or out.strip()}"
|
||||
return True, "Database created"
|
||||
|
||||
|
||||
def drop_mysql_database(db_name: str, username: str) -> tuple[bool, str]:
|
||||
"""Drop MySQL database and user."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured"
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters: {x}"
|
||||
steps = [
|
||||
(f"DROP DATABASE IF EXISTS `{db_name}`;", "drop database"),
|
||||
(f"DROP USER IF EXISTS '{username}'@'localhost';", "drop user"),
|
||||
("FLUSH PRIVILEGES;", "flush"),
|
||||
]
|
||||
for sql, step in steps:
|
||||
out, err = _run_mysql(sql, root_pw)
|
||||
if err and "error" in err.lower():
|
||||
return False, f"{step}: {err.strip() or out.strip()}"
|
||||
return True, "Database dropped"
|
||||
|
||||
|
||||
def backup_mysql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
|
||||
"""Create mysqldump backup. Returns (success, message, filename)."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured", None
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name", None
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{db_name}_{ts}.sql.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
env = os.environ.copy()
|
||||
env["MYSQL_PWD"] = root_pw
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'mysqldump -u root {db_name} | gzip > "{dest}"',
|
||||
shell=True,
|
||||
env=env,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0 or not os.path.isfile(dest):
|
||||
return False, r.stderr or r.stdout or "Backup failed", None
|
||||
return True, "Backup created", filename
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out", None
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def restore_mysql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
|
||||
"""Restore MySQL database from .sql.gz backup."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured"
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name"
|
||||
if not os.path.isfile(backup_path):
|
||||
return False, "Backup file not found"
|
||||
env = os.environ.copy()
|
||||
env["MYSQL_PWD"] = root_pw
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'gunzip -c "{backup_path}" | mysql -u root {db_name}',
|
||||
shell=True,
|
||||
env=env,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, r.stderr or r.stdout or "Restore failed"
|
||||
return True, "Restored"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def change_mysql_password(username: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change MySQL user password."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured"
|
||||
if not all(c.isalnum() or c == "_" for c in username):
|
||||
return False, "Invalid username"
|
||||
pw_esc = new_password.replace("\\", "\\\\").replace("'", "''")
|
||||
sql = f"ALTER USER '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';"
|
||||
out, err = _run_mysql(sql, root_pw)
|
||||
if err and "error" in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
return True, "Password updated"
|
||||
|
||||
|
||||
def create_postgresql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create PostgreSQL database and user via psql (runs as postgres). Returns (success, message)."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters in name: {x}"
|
||||
pw_esc = password.replace("'", "''")
|
||||
cmds = [
|
||||
f"CREATE DATABASE {db_name};",
|
||||
f"CREATE USER {username} WITH PASSWORD '{pw_esc}';",
|
||||
f"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {username};",
|
||||
f"GRANT ALL ON SCHEMA public TO {username};",
|
||||
]
|
||||
for sql in cmds:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", sql],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
if "already exists" in err.lower():
|
||||
continue
|
||||
return False, err
|
||||
except FileNotFoundError:
|
||||
return False, "PostgreSQL (psql) not found"
|
||||
return True, "Database created"
|
||||
|
||||
|
||||
def drop_postgresql_database(db_name: str, username: str) -> tuple[bool, str]:
|
||||
"""Drop PostgreSQL database and user."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters: {x}"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP DATABASE IF EXISTS {db_name};"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP USER IF EXISTS {username};"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Database dropped"
|
||||
except FileNotFoundError:
|
||||
return False, "PostgreSQL (psql) not found"
|
||||
|
||||
|
||||
def change_postgresql_password(username: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change PostgreSQL user password."""
|
||||
if not all(c.isalnum() or c == "_" for c in username):
|
||||
return False, "Invalid username"
|
||||
pw_esc = new_password.replace("'", "''")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", f"ALTER USER {username} WITH PASSWORD '{pw_esc}';"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Password updated"
|
||||
except FileNotFoundError:
|
||||
return False, "PostgreSQL (psql) not found"
|
||||
|
||||
|
||||
def create_mongodb_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create MongoDB database and user via mongosh."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters in name: {x}"
|
||||
pw_esc = password.replace("\\", "\\\\").replace("'", "\\'")
|
||||
js = (
|
||||
f"db = db.getSiblingDB('{db_name}'); "
|
||||
f"db.createUser({{user: '{username}', pwd: '{pw_esc}', roles: [{{role: 'readWrite', db: '{db_name}'}}]}});"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mongosh", "--quiet", "--eval", js],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
if "already exists" in err.lower():
|
||||
return True, "Database created"
|
||||
return False, err
|
||||
return True, "Database created"
|
||||
except FileNotFoundError:
|
||||
return False, "MongoDB (mongosh) not found"
|
||||
|
||||
|
||||
def drop_mongodb_database(db_name: str, username: str) -> tuple[bool, str]:
|
||||
"""Drop MongoDB database and user."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters: {x}"
|
||||
try:
|
||||
js = f"db = db.getSiblingDB('{db_name}'); db.dropUser('{username}'); db.dropDatabase();"
|
||||
r = subprocess.run(
|
||||
["mongosh", "--quiet", "--eval", js],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Database dropped"
|
||||
except FileNotFoundError:
|
||||
return False, "MongoDB (mongosh) not found"
|
||||
|
||||
|
||||
def backup_postgresql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
|
||||
"""Create PostgreSQL backup via pg_dump. Returns (success, message, filename)."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name", None
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{db_name}_{ts}.sql.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'sudo -u postgres pg_dump {db_name} | gzip > "{dest}"',
|
||||
shell=True,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0 or not os.path.isfile(dest):
|
||||
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
|
||||
return True, "Backup created", filename
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out", None
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def restore_postgresql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
|
||||
"""Restore PostgreSQL database from .sql.gz backup."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name"
|
||||
if not os.path.isfile(backup_path):
|
||||
return False, "Backup file not found"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'gunzip -c "{backup_path}" | sudo -u postgres psql -d {db_name}',
|
||||
shell=True,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Restore failed").strip()[:200]
|
||||
return True, "Restored"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def backup_mongodb_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
|
||||
"""Create MongoDB backup via mongodump. Returns (success, message, filename)."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name", None
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = os.path.join(backup_dir, f"{db_name}_{ts}")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mongodump", "--db", db_name, "--out", out_dir, "--gzip"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
|
||||
# Create tarball of the dump
|
||||
archive = out_dir + ".tar.gz"
|
||||
r2 = subprocess.run(
|
||||
f'tar -czf "{archive}" -C "{backup_dir}" "{os.path.basename(out_dir)}"',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if os.path.isdir(out_dir):
|
||||
shutil.rmtree(out_dir, ignore_errors=True)
|
||||
if r2.returncode != 0 or not os.path.isfile(archive):
|
||||
return False, "Archive failed", None
|
||||
return True, "Backup created", os.path.basename(archive)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out", None
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def restore_mongodb_database(db_name: str, backup_path: str) -> tuple[bool, str]:
|
||||
"""Restore MongoDB database from .tar.gz mongodump backup."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name"
|
||||
if not os.path.isfile(backup_path):
|
||||
return False, "Backup file not found"
|
||||
import tempfile
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["tar", "-xzf", backup_path, "-C", tmp],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Extract failed").strip()[:200]
|
||||
# Find the extracted dir (mongodump creates db_name/ subdir)
|
||||
extracted = os.path.join(tmp, os.listdir(tmp)[0]) if os.listdir(tmp) else None
|
||||
if not extracted or not os.path.isdir(extracted):
|
||||
return False, "Invalid backup format"
|
||||
r2 = subprocess.run(
|
||||
["mongorestore", "--db", db_name, "--gzip", "--drop", extracted],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r2.returncode != 0:
|
||||
return False, (r2.stderr or r2.stdout or "Restore failed").strip()[:200]
|
||||
return True, "Restored"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def change_mongodb_password(username: str, db_name: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change MongoDB user password."""
|
||||
if not all(c.isalnum() or c == "_" for c in username):
|
||||
return False, "Invalid username"
|
||||
pw_esc = new_password.replace("\\", "\\\\").replace("'", "\\'")
|
||||
js = f"db = db.getSiblingDB('{db_name}'); db.changeUserPassword('{username}', '{pw_esc}');"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mongosh", "--quiet", "--eval", js],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Password updated"
|
||||
except FileNotFoundError:
|
||||
return False, "MongoDB (mongosh) not found"
|
||||
69
YakPanel-server/backend/app/services/ftp_service.py
Normal file
69
YakPanel-server/backend/app/services/ftp_service.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""YakPanel - FTP service (Pure-FTPd via pure-pw)"""
|
||||
import os
|
||||
import subprocess
|
||||
from app.core.config import get_runtime_config
|
||||
|
||||
|
||||
def _run_pure_pw(args: str, stdin: str | None = None) -> tuple[str, str]:
|
||||
"""Run pure-pw command. Returns (stdout, stderr)."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f"pure-pw {args}",
|
||||
shell=True,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return r.stdout or "", r.stderr or ""
|
||||
except FileNotFoundError:
|
||||
return "", "pure-pw not found"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "Timed out"
|
||||
|
||||
|
||||
def create_ftp_user(name: str, password: str, path: str) -> tuple[bool, str]:
|
||||
"""Create Pure-FTPd virtual user via pure-pw."""
|
||||
if not all(c.isalnum() or c in "._-" for c in name):
|
||||
return False, "Invalid username"
|
||||
if ".." in path:
|
||||
return False, "Invalid path"
|
||||
path_abs = os.path.abspath(path)
|
||||
cfg = get_runtime_config()
|
||||
www_root = os.path.abspath(cfg["www_root"])
|
||||
if not (path_abs == www_root or path_abs.startswith(www_root + os.sep)):
|
||||
return False, "Path must be under www_root"
|
||||
os.makedirs(path_abs, exist_ok=True)
|
||||
# pure-pw useradd prompts for password twice; pipe it
|
||||
stdin = f"{password}\n{password}\n"
|
||||
out, err = _run_pure_pw(
|
||||
f'useradd {name} -u www-data -d "{path_abs}" -m',
|
||||
stdin=stdin,
|
||||
)
|
||||
if err and "error" in err.lower() and "already exists" not in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
out2, err2 = _run_pure_pw("mkdb")
|
||||
if err2 and "error" in err2.lower():
|
||||
return False, err2.strip() or out2.strip()
|
||||
return True, "FTP user created"
|
||||
|
||||
|
||||
def delete_ftp_user(name: str) -> tuple[bool, str]:
|
||||
"""Delete Pure-FTPd virtual user."""
|
||||
if not all(c.isalnum() or c in "._-" for c in name):
|
||||
return False, "Invalid username"
|
||||
out, err = _run_pure_pw(f'userdel {name} -m')
|
||||
if err and "error" in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
return True, "FTP user deleted"
|
||||
|
||||
|
||||
def update_ftp_password(name: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change Pure-FTPd user password."""
|
||||
if not all(c.isalnum() or c in "._-" for c in name):
|
||||
return False, "Invalid username"
|
||||
stdin = f"{new_password}\n{new_password}\n"
|
||||
out, err = _run_pure_pw(f'passwd {name} -m', stdin=stdin)
|
||||
if err and "error" in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
return True, "Password updated"
|
||||
338
YakPanel-server/backend/app/services/site_service.py
Normal file
338
YakPanel-server/backend/app/services/site_service.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""YakPanel - Site service"""
|
||||
import os
|
||||
import re
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.site import Site, Domain
|
||||
from app.models.redirect import SiteRedirect
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import path_safe_check, write_file, read_file, exec_shell_sync
|
||||
|
||||
|
||||
DOMAIN_REGEX = re.compile(r"^([\w\-\*]{1,100}\.){1,8}([\w\-]{1,24}|[\w\-]{1,24}\.[\w\-]{1,24})$")
|
||||
|
||||
|
||||
def _render_vhost(
|
||||
template: str,
|
||||
server_names: str,
|
||||
root_path: str,
|
||||
logs_path: str,
|
||||
site_name: str,
|
||||
php_version: str,
|
||||
force_https: int,
|
||||
redirects: list[tuple[str, str, int]] | None = None,
|
||||
) -> str:
|
||||
"""Render nginx vhost template. redirects: [(source, target, code), ...]"""
|
||||
force_block = "return 301 https://$host$request_uri;" if force_https else ""
|
||||
redirect_lines = []
|
||||
for src, tgt, code in (redirects or []):
|
||||
if src and tgt:
|
||||
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
|
||||
redirect_block = "\n".join(redirect_lines) if redirect_lines else ""
|
||||
content = template.replace("{SERVER_NAMES}", server_names)
|
||||
content = content.replace("{ROOT_PATH}", root_path)
|
||||
content = content.replace("{LOGS_PATH}", logs_path)
|
||||
content = content.replace("{SITE_NAME}", site_name)
|
||||
content = content.replace("{PHP_VERSION}", php_version or "74")
|
||||
content = content.replace("{FORCE_HTTPS_BLOCK}", force_block)
|
||||
content = content.replace("{REDIRECTS_BLOCK}", redirect_block)
|
||||
return content
|
||||
|
||||
|
||||
async def domain_format(domains: list[str]) -> str | None:
|
||||
"""Validate domain format. Returns first invalid domain or None."""
|
||||
for d in domains:
|
||||
if not DOMAIN_REGEX.match(d):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
async def domain_exists(db: AsyncSession, domains: list[str], exclude_site_id: int | None = None) -> str | None:
|
||||
"""Check if domain already exists. Returns first existing domain or None."""
|
||||
for d in domains:
|
||||
parts = d.split(":")
|
||||
name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
||||
q = select(Domain).where(Domain.name == name, Domain.port == port)
|
||||
if exclude_site_id is not None:
|
||||
q = q.where(Domain.pid != exclude_site_id)
|
||||
result = await db.execute(q)
|
||||
if result.scalar_one_or_none():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
async def create_site(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
path: str,
|
||||
domains: list[str],
|
||||
project_type: str = "PHP",
|
||||
ps: str = "",
|
||||
php_version: str = "74",
|
||||
force_https: int = 0,
|
||||
) -> dict:
|
||||
"""Create a new site with vhost config."""
|
||||
if not path_safe_check(name) or not path_safe_check(path):
|
||||
return {"status": False, "msg": "Invalid site name or path"}
|
||||
|
||||
invalid = await domain_format(domains)
|
||||
if invalid:
|
||||
return {"status": False, "msg": f"Invalid domain format: {invalid}"}
|
||||
|
||||
existing = await domain_exists(db, domains)
|
||||
if existing:
|
||||
return {"status": False, "msg": f"Domain already exists: {existing}"}
|
||||
|
||||
cfg = get_runtime_config()
|
||||
setup_path = cfg["setup_path"]
|
||||
www_root = cfg["www_root"]
|
||||
www_logs = cfg["www_logs"]
|
||||
vhost_path = os.path.join(setup_path, "panel", "vhost", "nginx")
|
||||
|
||||
site_path = os.path.join(www_root, name)
|
||||
if not os.path.exists(site_path):
|
||||
os.makedirs(site_path, 0o755)
|
||||
|
||||
site = Site(name=name, path=site_path, ps=ps, project_type=project_type, php_version=php_version or "74", force_https=force_https or 0)
|
||||
db.add(site)
|
||||
await db.flush()
|
||||
|
||||
for d in domains:
|
||||
parts = d.split(":")
|
||||
domain_name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
||||
db.add(Domain(pid=site.id, name=domain_name, port=port))
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Generate Nginx vhost
|
||||
conf_path = os.path.join(vhost_path, f"{name}.conf")
|
||||
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
||||
|
||||
if os.path.exists(template_path):
|
||||
template = read_file(template_path) or ""
|
||||
server_names = " ".join(d.split(":")[0] for d in domains)
|
||||
content = _render_vhost(template, server_names, site_path, www_logs, name, php_version or "74", force_https or 0, [])
|
||||
write_file(conf_path, content)
|
||||
|
||||
# Reload Nginx if available
|
||||
nginx_bin = os.path.join(setup_path, "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Site created", "id": site.id}
|
||||
|
||||
|
||||
async def list_sites(db: AsyncSession) -> list[dict]:
|
||||
"""List all sites with domain count."""
|
||||
result = await db.execute(select(Site).order_by(Site.id))
|
||||
sites = result.scalars().all()
|
||||
out = []
|
||||
for s in sites:
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == s.id))
|
||||
domains = domain_result.scalars().all()
|
||||
out.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"path": s.path,
|
||||
"status": s.status,
|
||||
"ps": s.ps,
|
||||
"project_type": s.project_type,
|
||||
"domain_count": len(domains),
|
||||
"addtime": s.addtime.isoformat() if s.addtime else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def delete_site(db: AsyncSession, site_id: int) -> dict:
|
||||
"""Delete a site and its vhost config."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
|
||||
await db.execute(Domain.__table__.delete().where(Domain.pid == site_id))
|
||||
await db.execute(SiteRedirect.__table__.delete().where(SiteRedirect.site_id == site_id))
|
||||
await db.delete(site)
|
||||
|
||||
cfg = get_runtime_config()
|
||||
conf_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx", f"{site.name}.conf")
|
||||
if os.path.exists(conf_path):
|
||||
os.remove(conf_path)
|
||||
|
||||
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -s reload")
|
||||
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Site deleted"}
|
||||
|
||||
|
||||
async def get_site_count(db: AsyncSession) -> int:
|
||||
"""Get total site count."""
|
||||
from sqlalchemy import func
|
||||
result = await db.execute(select(func.count()).select_from(Site))
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None:
|
||||
"""Get site with domain list for editing."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return None
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
||||
domains = domain_result.scalars().all()
|
||||
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domains]
|
||||
return {
|
||||
"id": site.id,
|
||||
"name": site.name,
|
||||
"path": site.path,
|
||||
"status": site.status,
|
||||
"ps": site.ps,
|
||||
"project_type": site.project_type,
|
||||
"php_version": getattr(site, "php_version", None) or "74",
|
||||
"force_https": getattr(site, "force_https", 0) or 0,
|
||||
"domains": domain_list,
|
||||
}
|
||||
|
||||
|
||||
async def update_site(
|
||||
db: AsyncSession,
|
||||
site_id: int,
|
||||
path: str | None = None,
|
||||
domains: list[str] | None = None,
|
||||
ps: str | None = None,
|
||||
php_version: str | None = None,
|
||||
force_https: int | None = None,
|
||||
) -> dict:
|
||||
"""Update site domains, path, or note."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
|
||||
if domains is not None:
|
||||
invalid = await domain_format(domains)
|
||||
if invalid:
|
||||
return {"status": False, "msg": f"Invalid domain format: {invalid}"}
|
||||
existing = await domain_exists(db, domains, exclude_site_id=site_id)
|
||||
if existing:
|
||||
return {"status": False, "msg": f"Domain already exists: {existing}"}
|
||||
await db.execute(Domain.__table__.delete().where(Domain.pid == site_id))
|
||||
for d in domains:
|
||||
parts = d.split(":")
|
||||
domain_name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
||||
db.add(Domain(pid=site.id, name=domain_name, port=port))
|
||||
|
||||
if path is not None and path_safe_check(path):
|
||||
site.path = path
|
||||
|
||||
if ps is not None:
|
||||
site.ps = ps
|
||||
if php_version is not None:
|
||||
site.php_version = php_version or "74"
|
||||
if force_https is not None:
|
||||
site.force_https = 1 if force_https else 0
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Regenerate Nginx vhost if domains, php_version, or force_https changed
|
||||
if domains is not None or php_version is not None or force_https is not None:
|
||||
cfg = get_runtime_config()
|
||||
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
||||
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
||||
if os.path.exists(template_path):
|
||||
template = read_file(template_path) or ""
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
||||
domain_rows = domain_result.scalars().all()
|
||||
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domain_rows]
|
||||
server_names = " ".join(d.split(":")[0] for d in domain_list) if domain_list else site.name
|
||||
php_ver = getattr(site, "php_version", None) or "74"
|
||||
fhttps = getattr(site, "force_https", 0) or 0
|
||||
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
||||
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
||||
content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects)
|
||||
write_file(conf_path, content)
|
||||
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Site updated"}
|
||||
|
||||
|
||||
def _vhost_path(site_name: str) -> tuple[str, str]:
|
||||
"""Return (conf_path, disabled_path) for site vhost."""
|
||||
cfg = get_runtime_config()
|
||||
vhost_dir = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||
disabled_dir = os.path.join(vhost_dir, "disabled")
|
||||
return (
|
||||
os.path.join(vhost_dir, f"{site_name}.conf"),
|
||||
os.path.join(disabled_dir, f"{site_name}.conf"),
|
||||
)
|
||||
|
||||
|
||||
async def set_site_status(db: AsyncSession, site_id: int, status: int) -> dict:
|
||||
"""Enable (1) or disable (0) site by moving vhost config."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
|
||||
conf_path, disabled_path = _vhost_path(site.name)
|
||||
disabled_dir = os.path.dirname(disabled_path)
|
||||
|
||||
if status == 1: # enable
|
||||
if os.path.isfile(disabled_path):
|
||||
os.makedirs(os.path.dirname(conf_path), exist_ok=True)
|
||||
os.rename(disabled_path, conf_path)
|
||||
else: # disable
|
||||
if os.path.isfile(conf_path):
|
||||
os.makedirs(disabled_dir, exist_ok=True)
|
||||
os.rename(conf_path, disabled_path)
|
||||
|
||||
site.status = status
|
||||
await db.commit()
|
||||
|
||||
nginx_bin = os.path.join(get_runtime_config()["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
|
||||
return {"status": True, "msg": "Site " + ("enabled" if status == 1 else "disabled")}
|
||||
|
||||
|
||||
async def regenerate_site_vhost(db: AsyncSession, site_id: int) -> dict:
|
||||
"""Regenerate nginx vhost for a site (e.g. after redirect changes)."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
cfg = get_runtime_config()
|
||||
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
||||
if site.status != 1:
|
||||
return {"status": True, "msg": "Site disabled, vhost not active"}
|
||||
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
||||
if not os.path.exists(template_path):
|
||||
return {"status": False, "msg": "Template not found"}
|
||||
template = read_file(template_path) or ""
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
||||
domain_rows = domain_result.scalars().all()
|
||||
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domain_rows]
|
||||
server_names = " ".join(d.split(":")[0] for d in domain_list) if domain_list else site.name
|
||||
php_ver = getattr(site, "php_version", None) or "74"
|
||||
fhttps = getattr(site, "force_https", 0) or 0
|
||||
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
||||
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
||||
content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects)
|
||||
write_file(conf_path, content)
|
||||
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
return {"status": True, "msg": "Vhost regenerated"}
|
||||
4
YakPanel-server/backend/app/tasks/__init__.py
Normal file
4
YakPanel-server/backend/app/tasks/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# YakPanel - Celery tasks
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
20
YakPanel-server/backend/app/tasks/celery_app.py
Normal file
20
YakPanel-server/backend/app/tasks/celery_app.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""YakPanel - Celery application"""
|
||||
from celery import Celery
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
celery_app = Celery(
|
||||
"cit_panel",
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
include=["app.tasks.install"],
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
)
|
||||
42
YakPanel-server/backend/app/tasks/install.py
Normal file
42
YakPanel-server/backend/app/tasks/install.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""YakPanel - Install tasks (one-click install via apt)"""
|
||||
import subprocess
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
# Map panel package IDs to apt package names (same as soft.py)
|
||||
PKG_MAP = {
|
||||
"nginx": "nginx",
|
||||
"mysql-server": "mysql-server",
|
||||
"mariadb-server": "mariadb-server",
|
||||
"php": "php",
|
||||
"php-fpm": "php-fpm",
|
||||
"redis-server": "redis-server",
|
||||
"postgresql": "postgresql",
|
||||
"mongodb": "mongodb",
|
||||
"certbot": "certbot",
|
||||
"docker": "docker.io",
|
||||
"nodejs": "nodejs",
|
||||
"npm": "npm",
|
||||
"git": "git",
|
||||
"python3": "python3",
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def install_software(name: str, version: str = ""):
|
||||
"""Install software via apt (Debian/Ubuntu). name = package id from soft list."""
|
||||
pkg = PKG_MAP.get(name, name)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f"apt-get update && apt-get install -y {pkg}",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
err = (result.stderr or result.stdout or b"").decode("utf-8", errors="replace")
|
||||
return {"status": "failed", "name": name, "error": err.strip()[:500]}
|
||||
return {"status": "ok", "name": name, "version": version}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "failed", "name": name, "error": "Installation timed out"}
|
||||
except Exception as e:
|
||||
return {"status": "failed", "name": name, "error": str(e)}
|
||||
Reference in New Issue
Block a user