Initial YakPanel commit

This commit is contained in:
Niranjan
2026-04-07 02:04:22 +05:30
commit 2826d3e7f3
5359 changed files with 1390724 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
# YakPanel - Environment
SECRET_KEY=change-this-in-production
DATABASE_URL=sqlite+aiosqlite:///./data/default.db
REDIS_URL=redis://localhost:6379/0
DEBUG=false
PANEL_PORT=8888
# Optional: remote SSH installer (use with caution)
# ENABLE_REMOTE_INSTALLER=false
# REMOTE_INSTALL_DEFAULT_URL=https://www.yakpanel.com/YakPanel-server/install.sh
# REMOTE_INSTALL_RATE_LIMIT_PER_IP=10
# REMOTE_INSTALL_RATE_WINDOW_MINUTES=60
# REMOTE_INSTALL_ALLOWED_TARGET_CIDRS=
# CORS_EXTRA_ORIGINS=https://your-panel.example.com

View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONPATH=/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8888"]

View File

@@ -0,0 +1 @@
# YakPanel - Backend Application

View File

@@ -0,0 +1 @@
# YakPanel - API routes

View 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"}

View 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}

View 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"}

View 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"}

View 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),
},
}

View 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"}

View 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"}

View 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"}

View 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)}

View 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}

View 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())}

View 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),
}

View 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"}

View 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"}

View 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()

View 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"}

View 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"}

View 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"}

View 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"])}

View 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())

View 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}

View File

@@ -0,0 +1 @@
# YakPanel - Core module

View 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)

View 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)

View 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)

View 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

View 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 = {"&quot;": '"', "&quot": '"', "&#x27;": "'", "&#x27": "'"}
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)

View 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"}

View 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"]

View 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)

View 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="")

View 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)

View 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)

View 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="")

View 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)

View 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)

View 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

View 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)

View 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)

View File

@@ -0,0 +1 @@
# YakPanel - Services

View 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

View 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"

View 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"

View 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"}

View File

@@ -0,0 +1,4 @@
# YakPanel - Celery tasks
from app.tasks.celery_app import celery_app
__all__ = ["celery_app"]

View 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,
)

View 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)}

View File

@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "yakpanel-server"
version = "1.0.0"
description = "YakPanel - Web hosting control panel"
requires-python = ">=3.11"
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]

View File

@@ -0,0 +1,28 @@
# YakPanel - Backend Dependencies
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
python-multipart>=0.0.6
# Database
sqlalchemy>=2.0.25
alembic>=1.13.0
aiosqlite>=0.19.0
asyncpg>=0.29.0
# Auth
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-dotenv>=1.0.0
# Redis & Celery
redis>=5.0.0
celery>=5.3.0
# Utils
psutil>=5.9.0
croniter>=2.0.0
pydantic>=2.5.0
pydantic-settings>=2.1.0
# Remote SSH installer (optional)
asyncssh>=2.14.0

View File

@@ -0,0 +1,5 @@
"""Run YakPanel backend"""
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=8888, reload=True)

View File

@@ -0,0 +1,35 @@
"""Seed admin user for YakPanel"""
import asyncio
import sys
import os
# Run from backend directory
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(backend_dir)
sys.path.insert(0, backend_dir)
from sqlalchemy import select
from app.core.database import AsyncSessionLocal, init_db
from app.core.security import get_password_hash
from app.models.user import User
async def seed():
await init_db()
async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.username == "admin"))
if result.scalar_one_or_none():
print("Admin user already exists")
return
admin = User(
username="admin",
password=get_password_hash("admin"),
is_superuser=True,
)
db.add(admin)
await db.commit()
print("Admin user created: username=admin, password=admin")
if __name__ == "__main__":
asyncio.run(seed())