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 @@
# 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}