Initial YakPanel commit
This commit is contained in:
1
YakPanel-server/backend/app/services/__init__.py
Normal file
1
YakPanel-server/backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# YakPanel - Services
|
||||
49
YakPanel-server/backend/app/services/config_service.py
Normal file
49
YakPanel-server/backend/app/services/config_service.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""YakPanel - Config service (panel settings)"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.config import Config
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
async def get_config_value(db: AsyncSession, key: str) -> str:
|
||||
"""Get config value by key"""
|
||||
result = await db.execute(select(Config).where(Config.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
return row.value if row else ""
|
||||
|
||||
|
||||
async def set_config_value(db: AsyncSession, key: str, value: str) -> None:
|
||||
"""Set config value"""
|
||||
result = await db.execute(select(Config).where(Config.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
db.add(Config(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
|
||||
def get_webserver_type() -> str:
|
||||
"""Get webserver type (nginx, apache, openlitespeed)"""
|
||||
return get_settings().webserver_type
|
||||
|
||||
|
||||
def get_setup_path() -> str:
|
||||
"""Get server setup path"""
|
||||
return get_settings().setup_path
|
||||
|
||||
|
||||
def get_www_root() -> str:
|
||||
"""Get www root path"""
|
||||
return get_settings().www_root
|
||||
|
||||
|
||||
def get_www_logs() -> str:
|
||||
"""Get www logs path"""
|
||||
return get_settings().www_logs
|
||||
|
||||
|
||||
def get_vhost_path() -> str:
|
||||
"""Get vhost config path"""
|
||||
return get_settings().vhost_path
|
||||
411
YakPanel-server/backend/app/services/database_service.py
Normal file
411
YakPanel-server/backend/app/services/database_service.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""YakPanel - Database service (MySQL creation via CLI; PostgreSQL/MongoDB/Redis panel records only)"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from app.core.config import get_runtime_config
|
||||
|
||||
|
||||
def get_mysql_root() -> str | None:
|
||||
"""Get MySQL root password from config (key: mysql_root)"""
|
||||
cfg = get_runtime_config()
|
||||
return cfg.get("mysql_root") or None
|
||||
|
||||
|
||||
def _run_mysql(sql: str, root_pw: str) -> tuple[str, str]:
|
||||
"""Run mysql with SQL. Uses MYSQL_PWD to avoid password in argv."""
|
||||
env = os.environ.copy()
|
||||
env["MYSQL_PWD"] = root_pw
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mysql", "-u", "root", "-e", sql],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env=env,
|
||||
)
|
||||
return r.stdout or "", r.stderr or ""
|
||||
except FileNotFoundError:
|
||||
return "", "mysql command not found"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "Timed out"
|
||||
|
||||
|
||||
def create_mysql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create MySQL database and user via mysql CLI. Returns (success, message)."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured. Set it in Settings."
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters in name: {x}"
|
||||
pw_esc = password.replace("\\", "\\\\").replace("'", "''")
|
||||
steps = [
|
||||
(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;", "create database"),
|
||||
(f"CREATE USER IF NOT EXISTS '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';", "create user"),
|
||||
(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{username}'@'localhost';", "grant"),
|
||||
("FLUSH PRIVILEGES;", "flush"),
|
||||
]
|
||||
for sql, step in steps:
|
||||
out, err = _run_mysql(sql, root_pw)
|
||||
if err and "error" in err.lower() and "already exists" not in err.lower():
|
||||
return False, f"{step}: {err.strip() or out.strip()}"
|
||||
return True, "Database created"
|
||||
|
||||
|
||||
def drop_mysql_database(db_name: str, username: str) -> tuple[bool, str]:
|
||||
"""Drop MySQL database and user."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured"
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters: {x}"
|
||||
steps = [
|
||||
(f"DROP DATABASE IF EXISTS `{db_name}`;", "drop database"),
|
||||
(f"DROP USER IF EXISTS '{username}'@'localhost';", "drop user"),
|
||||
("FLUSH PRIVILEGES;", "flush"),
|
||||
]
|
||||
for sql, step in steps:
|
||||
out, err = _run_mysql(sql, root_pw)
|
||||
if err and "error" in err.lower():
|
||||
return False, f"{step}: {err.strip() or out.strip()}"
|
||||
return True, "Database dropped"
|
||||
|
||||
|
||||
def backup_mysql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
|
||||
"""Create mysqldump backup. Returns (success, message, filename)."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured", None
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name", None
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{db_name}_{ts}.sql.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
env = os.environ.copy()
|
||||
env["MYSQL_PWD"] = root_pw
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'mysqldump -u root {db_name} | gzip > "{dest}"',
|
||||
shell=True,
|
||||
env=env,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0 or not os.path.isfile(dest):
|
||||
return False, r.stderr or r.stdout or "Backup failed", None
|
||||
return True, "Backup created", filename
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out", None
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def restore_mysql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
|
||||
"""Restore MySQL database from .sql.gz backup."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured"
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name"
|
||||
if not os.path.isfile(backup_path):
|
||||
return False, "Backup file not found"
|
||||
env = os.environ.copy()
|
||||
env["MYSQL_PWD"] = root_pw
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'gunzip -c "{backup_path}" | mysql -u root {db_name}',
|
||||
shell=True,
|
||||
env=env,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, r.stderr or r.stdout or "Restore failed"
|
||||
return True, "Restored"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def change_mysql_password(username: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change MySQL user password."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured"
|
||||
if not all(c.isalnum() or c == "_" for c in username):
|
||||
return False, "Invalid username"
|
||||
pw_esc = new_password.replace("\\", "\\\\").replace("'", "''")
|
||||
sql = f"ALTER USER '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';"
|
||||
out, err = _run_mysql(sql, root_pw)
|
||||
if err and "error" in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
return True, "Password updated"
|
||||
|
||||
|
||||
def create_postgresql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create PostgreSQL database and user via psql (runs as postgres). Returns (success, message)."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters in name: {x}"
|
||||
pw_esc = password.replace("'", "''")
|
||||
cmds = [
|
||||
f"CREATE DATABASE {db_name};",
|
||||
f"CREATE USER {username} WITH PASSWORD '{pw_esc}';",
|
||||
f"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {username};",
|
||||
f"GRANT ALL ON SCHEMA public TO {username};",
|
||||
]
|
||||
for sql in cmds:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", sql],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
if "already exists" in err.lower():
|
||||
continue
|
||||
return False, err
|
||||
except FileNotFoundError:
|
||||
return False, "PostgreSQL (psql) not found"
|
||||
return True, "Database created"
|
||||
|
||||
|
||||
def drop_postgresql_database(db_name: str, username: str) -> tuple[bool, str]:
|
||||
"""Drop PostgreSQL database and user."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters: {x}"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP DATABASE IF EXISTS {db_name};"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP USER IF EXISTS {username};"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Database dropped"
|
||||
except FileNotFoundError:
|
||||
return False, "PostgreSQL (psql) not found"
|
||||
|
||||
|
||||
def change_postgresql_password(username: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change PostgreSQL user password."""
|
||||
if not all(c.isalnum() or c == "_" for c in username):
|
||||
return False, "Invalid username"
|
||||
pw_esc = new_password.replace("'", "''")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", f"ALTER USER {username} WITH PASSWORD '{pw_esc}';"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Password updated"
|
||||
except FileNotFoundError:
|
||||
return False, "PostgreSQL (psql) not found"
|
||||
|
||||
|
||||
def create_mongodb_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create MongoDB database and user via mongosh."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters in name: {x}"
|
||||
pw_esc = password.replace("\\", "\\\\").replace("'", "\\'")
|
||||
js = (
|
||||
f"db = db.getSiblingDB('{db_name}'); "
|
||||
f"db.createUser({{user: '{username}', pwd: '{pw_esc}', roles: [{{role: 'readWrite', db: '{db_name}'}}]}});"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mongosh", "--quiet", "--eval", js],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
if "already exists" in err.lower():
|
||||
return True, "Database created"
|
||||
return False, err
|
||||
return True, "Database created"
|
||||
except FileNotFoundError:
|
||||
return False, "MongoDB (mongosh) not found"
|
||||
|
||||
|
||||
def drop_mongodb_database(db_name: str, username: str) -> tuple[bool, str]:
|
||||
"""Drop MongoDB database and user."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters: {x}"
|
||||
try:
|
||||
js = f"db = db.getSiblingDB('{db_name}'); db.dropUser('{username}'); db.dropDatabase();"
|
||||
r = subprocess.run(
|
||||
["mongosh", "--quiet", "--eval", js],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Database dropped"
|
||||
except FileNotFoundError:
|
||||
return False, "MongoDB (mongosh) not found"
|
||||
|
||||
|
||||
def backup_postgresql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
|
||||
"""Create PostgreSQL backup via pg_dump. Returns (success, message, filename)."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name", None
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{db_name}_{ts}.sql.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'sudo -u postgres pg_dump {db_name} | gzip > "{dest}"',
|
||||
shell=True,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0 or not os.path.isfile(dest):
|
||||
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
|
||||
return True, "Backup created", filename
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out", None
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def restore_postgresql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
|
||||
"""Restore PostgreSQL database from .sql.gz backup."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name"
|
||||
if not os.path.isfile(backup_path):
|
||||
return False, "Backup file not found"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'gunzip -c "{backup_path}" | sudo -u postgres psql -d {db_name}',
|
||||
shell=True,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Restore failed").strip()[:200]
|
||||
return True, "Restored"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def backup_mongodb_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
|
||||
"""Create MongoDB backup via mongodump. Returns (success, message, filename)."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name", None
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = os.path.join(backup_dir, f"{db_name}_{ts}")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mongodump", "--db", db_name, "--out", out_dir, "--gzip"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
|
||||
# Create tarball of the dump
|
||||
archive = out_dir + ".tar.gz"
|
||||
r2 = subprocess.run(
|
||||
f'tar -czf "{archive}" -C "{backup_dir}" "{os.path.basename(out_dir)}"',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if os.path.isdir(out_dir):
|
||||
shutil.rmtree(out_dir, ignore_errors=True)
|
||||
if r2.returncode != 0 or not os.path.isfile(archive):
|
||||
return False, "Archive failed", None
|
||||
return True, "Backup created", os.path.basename(archive)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out", None
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def restore_mongodb_database(db_name: str, backup_path: str) -> tuple[bool, str]:
|
||||
"""Restore MongoDB database from .tar.gz mongodump backup."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name"
|
||||
if not os.path.isfile(backup_path):
|
||||
return False, "Backup file not found"
|
||||
import tempfile
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["tar", "-xzf", backup_path, "-C", tmp],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Extract failed").strip()[:200]
|
||||
# Find the extracted dir (mongodump creates db_name/ subdir)
|
||||
extracted = os.path.join(tmp, os.listdir(tmp)[0]) if os.listdir(tmp) else None
|
||||
if not extracted or not os.path.isdir(extracted):
|
||||
return False, "Invalid backup format"
|
||||
r2 = subprocess.run(
|
||||
["mongorestore", "--db", db_name, "--gzip", "--drop", extracted],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r2.returncode != 0:
|
||||
return False, (r2.stderr or r2.stdout or "Restore failed").strip()[:200]
|
||||
return True, "Restored"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def change_mongodb_password(username: str, db_name: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change MongoDB user password."""
|
||||
if not all(c.isalnum() or c == "_" for c in username):
|
||||
return False, "Invalid username"
|
||||
pw_esc = new_password.replace("\\", "\\\\").replace("'", "\\'")
|
||||
js = f"db = db.getSiblingDB('{db_name}'); db.changeUserPassword('{username}', '{pw_esc}');"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mongosh", "--quiet", "--eval", js],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Password updated"
|
||||
except FileNotFoundError:
|
||||
return False, "MongoDB (mongosh) not found"
|
||||
69
YakPanel-server/backend/app/services/ftp_service.py
Normal file
69
YakPanel-server/backend/app/services/ftp_service.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""YakPanel - FTP service (Pure-FTPd via pure-pw)"""
|
||||
import os
|
||||
import subprocess
|
||||
from app.core.config import get_runtime_config
|
||||
|
||||
|
||||
def _run_pure_pw(args: str, stdin: str | None = None) -> tuple[str, str]:
|
||||
"""Run pure-pw command. Returns (stdout, stderr)."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f"pure-pw {args}",
|
||||
shell=True,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return r.stdout or "", r.stderr or ""
|
||||
except FileNotFoundError:
|
||||
return "", "pure-pw not found"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "Timed out"
|
||||
|
||||
|
||||
def create_ftp_user(name: str, password: str, path: str) -> tuple[bool, str]:
|
||||
"""Create Pure-FTPd virtual user via pure-pw."""
|
||||
if not all(c.isalnum() or c in "._-" for c in name):
|
||||
return False, "Invalid username"
|
||||
if ".." in path:
|
||||
return False, "Invalid path"
|
||||
path_abs = os.path.abspath(path)
|
||||
cfg = get_runtime_config()
|
||||
www_root = os.path.abspath(cfg["www_root"])
|
||||
if not (path_abs == www_root or path_abs.startswith(www_root + os.sep)):
|
||||
return False, "Path must be under www_root"
|
||||
os.makedirs(path_abs, exist_ok=True)
|
||||
# pure-pw useradd prompts for password twice; pipe it
|
||||
stdin = f"{password}\n{password}\n"
|
||||
out, err = _run_pure_pw(
|
||||
f'useradd {name} -u www-data -d "{path_abs}" -m',
|
||||
stdin=stdin,
|
||||
)
|
||||
if err and "error" in err.lower() and "already exists" not in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
out2, err2 = _run_pure_pw("mkdb")
|
||||
if err2 and "error" in err2.lower():
|
||||
return False, err2.strip() or out2.strip()
|
||||
return True, "FTP user created"
|
||||
|
||||
|
||||
def delete_ftp_user(name: str) -> tuple[bool, str]:
|
||||
"""Delete Pure-FTPd virtual user."""
|
||||
if not all(c.isalnum() or c in "._-" for c in name):
|
||||
return False, "Invalid username"
|
||||
out, err = _run_pure_pw(f'userdel {name} -m')
|
||||
if err and "error" in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
return True, "FTP user deleted"
|
||||
|
||||
|
||||
def update_ftp_password(name: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change Pure-FTPd user password."""
|
||||
if not all(c.isalnum() or c in "._-" for c in name):
|
||||
return False, "Invalid username"
|
||||
stdin = f"{new_password}\n{new_password}\n"
|
||||
out, err = _run_pure_pw(f'passwd {name} -m', stdin=stdin)
|
||||
if err and "error" in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
return True, "Password updated"
|
||||
338
YakPanel-server/backend/app/services/site_service.py
Normal file
338
YakPanel-server/backend/app/services/site_service.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""YakPanel - Site service"""
|
||||
import os
|
||||
import re
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.site import Site, Domain
|
||||
from app.models.redirect import SiteRedirect
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import path_safe_check, write_file, read_file, exec_shell_sync
|
||||
|
||||
|
||||
DOMAIN_REGEX = re.compile(r"^([\w\-\*]{1,100}\.){1,8}([\w\-]{1,24}|[\w\-]{1,24}\.[\w\-]{1,24})$")
|
||||
|
||||
|
||||
def _render_vhost(
|
||||
template: str,
|
||||
server_names: str,
|
||||
root_path: str,
|
||||
logs_path: str,
|
||||
site_name: str,
|
||||
php_version: str,
|
||||
force_https: int,
|
||||
redirects: list[tuple[str, str, int]] | None = None,
|
||||
) -> str:
|
||||
"""Render nginx vhost template. redirects: [(source, target, code), ...]"""
|
||||
force_block = "return 301 https://$host$request_uri;" if force_https else ""
|
||||
redirect_lines = []
|
||||
for src, tgt, code in (redirects or []):
|
||||
if src and tgt:
|
||||
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
|
||||
redirect_block = "\n".join(redirect_lines) if redirect_lines else ""
|
||||
content = template.replace("{SERVER_NAMES}", server_names)
|
||||
content = content.replace("{ROOT_PATH}", root_path)
|
||||
content = content.replace("{LOGS_PATH}", logs_path)
|
||||
content = content.replace("{SITE_NAME}", site_name)
|
||||
content = content.replace("{PHP_VERSION}", php_version or "74")
|
||||
content = content.replace("{FORCE_HTTPS_BLOCK}", force_block)
|
||||
content = content.replace("{REDIRECTS_BLOCK}", redirect_block)
|
||||
return content
|
||||
|
||||
|
||||
async def domain_format(domains: list[str]) -> str | None:
|
||||
"""Validate domain format. Returns first invalid domain or None."""
|
||||
for d in domains:
|
||||
if not DOMAIN_REGEX.match(d):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
async def domain_exists(db: AsyncSession, domains: list[str], exclude_site_id: int | None = None) -> str | None:
|
||||
"""Check if domain already exists. Returns first existing domain or None."""
|
||||
for d in domains:
|
||||
parts = d.split(":")
|
||||
name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
||||
q = select(Domain).where(Domain.name == name, Domain.port == port)
|
||||
if exclude_site_id is not None:
|
||||
q = q.where(Domain.pid != exclude_site_id)
|
||||
result = await db.execute(q)
|
||||
if result.scalar_one_or_none():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
async def create_site(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
path: str,
|
||||
domains: list[str],
|
||||
project_type: str = "PHP",
|
||||
ps: str = "",
|
||||
php_version: str = "74",
|
||||
force_https: int = 0,
|
||||
) -> dict:
|
||||
"""Create a new site with vhost config."""
|
||||
if not path_safe_check(name) or not path_safe_check(path):
|
||||
return {"status": False, "msg": "Invalid site name or path"}
|
||||
|
||||
invalid = await domain_format(domains)
|
||||
if invalid:
|
||||
return {"status": False, "msg": f"Invalid domain format: {invalid}"}
|
||||
|
||||
existing = await domain_exists(db, domains)
|
||||
if existing:
|
||||
return {"status": False, "msg": f"Domain already exists: {existing}"}
|
||||
|
||||
cfg = get_runtime_config()
|
||||
setup_path = cfg["setup_path"]
|
||||
www_root = cfg["www_root"]
|
||||
www_logs = cfg["www_logs"]
|
||||
vhost_path = os.path.join(setup_path, "panel", "vhost", "nginx")
|
||||
|
||||
site_path = os.path.join(www_root, name)
|
||||
if not os.path.exists(site_path):
|
||||
os.makedirs(site_path, 0o755)
|
||||
|
||||
site = Site(name=name, path=site_path, ps=ps, project_type=project_type, php_version=php_version or "74", force_https=force_https or 0)
|
||||
db.add(site)
|
||||
await db.flush()
|
||||
|
||||
for d in domains:
|
||||
parts = d.split(":")
|
||||
domain_name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
||||
db.add(Domain(pid=site.id, name=domain_name, port=port))
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Generate Nginx vhost
|
||||
conf_path = os.path.join(vhost_path, f"{name}.conf")
|
||||
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
||||
|
||||
if os.path.exists(template_path):
|
||||
template = read_file(template_path) or ""
|
||||
server_names = " ".join(d.split(":")[0] for d in domains)
|
||||
content = _render_vhost(template, server_names, site_path, www_logs, name, php_version or "74", force_https or 0, [])
|
||||
write_file(conf_path, content)
|
||||
|
||||
# Reload Nginx if available
|
||||
nginx_bin = os.path.join(setup_path, "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Site created", "id": site.id}
|
||||
|
||||
|
||||
async def list_sites(db: AsyncSession) -> list[dict]:
|
||||
"""List all sites with domain count."""
|
||||
result = await db.execute(select(Site).order_by(Site.id))
|
||||
sites = result.scalars().all()
|
||||
out = []
|
||||
for s in sites:
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == s.id))
|
||||
domains = domain_result.scalars().all()
|
||||
out.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"path": s.path,
|
||||
"status": s.status,
|
||||
"ps": s.ps,
|
||||
"project_type": s.project_type,
|
||||
"domain_count": len(domains),
|
||||
"addtime": s.addtime.isoformat() if s.addtime else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def delete_site(db: AsyncSession, site_id: int) -> dict:
|
||||
"""Delete a site and its vhost config."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
|
||||
await db.execute(Domain.__table__.delete().where(Domain.pid == site_id))
|
||||
await db.execute(SiteRedirect.__table__.delete().where(SiteRedirect.site_id == site_id))
|
||||
await db.delete(site)
|
||||
|
||||
cfg = get_runtime_config()
|
||||
conf_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx", f"{site.name}.conf")
|
||||
if os.path.exists(conf_path):
|
||||
os.remove(conf_path)
|
||||
|
||||
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -s reload")
|
||||
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Site deleted"}
|
||||
|
||||
|
||||
async def get_site_count(db: AsyncSession) -> int:
|
||||
"""Get total site count."""
|
||||
from sqlalchemy import func
|
||||
result = await db.execute(select(func.count()).select_from(Site))
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None:
|
||||
"""Get site with domain list for editing."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return None
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
||||
domains = domain_result.scalars().all()
|
||||
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domains]
|
||||
return {
|
||||
"id": site.id,
|
||||
"name": site.name,
|
||||
"path": site.path,
|
||||
"status": site.status,
|
||||
"ps": site.ps,
|
||||
"project_type": site.project_type,
|
||||
"php_version": getattr(site, "php_version", None) or "74",
|
||||
"force_https": getattr(site, "force_https", 0) or 0,
|
||||
"domains": domain_list,
|
||||
}
|
||||
|
||||
|
||||
async def update_site(
|
||||
db: AsyncSession,
|
||||
site_id: int,
|
||||
path: str | None = None,
|
||||
domains: list[str] | None = None,
|
||||
ps: str | None = None,
|
||||
php_version: str | None = None,
|
||||
force_https: int | None = None,
|
||||
) -> dict:
|
||||
"""Update site domains, path, or note."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
|
||||
if domains is not None:
|
||||
invalid = await domain_format(domains)
|
||||
if invalid:
|
||||
return {"status": False, "msg": f"Invalid domain format: {invalid}"}
|
||||
existing = await domain_exists(db, domains, exclude_site_id=site_id)
|
||||
if existing:
|
||||
return {"status": False, "msg": f"Domain already exists: {existing}"}
|
||||
await db.execute(Domain.__table__.delete().where(Domain.pid == site_id))
|
||||
for d in domains:
|
||||
parts = d.split(":")
|
||||
domain_name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
||||
db.add(Domain(pid=site.id, name=domain_name, port=port))
|
||||
|
||||
if path is not None and path_safe_check(path):
|
||||
site.path = path
|
||||
|
||||
if ps is not None:
|
||||
site.ps = ps
|
||||
if php_version is not None:
|
||||
site.php_version = php_version or "74"
|
||||
if force_https is not None:
|
||||
site.force_https = 1 if force_https else 0
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Regenerate Nginx vhost if domains, php_version, or force_https changed
|
||||
if domains is not None or php_version is not None or force_https is not None:
|
||||
cfg = get_runtime_config()
|
||||
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
||||
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
||||
if os.path.exists(template_path):
|
||||
template = read_file(template_path) or ""
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
||||
domain_rows = domain_result.scalars().all()
|
||||
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domain_rows]
|
||||
server_names = " ".join(d.split(":")[0] for d in domain_list) if domain_list else site.name
|
||||
php_ver = getattr(site, "php_version", None) or "74"
|
||||
fhttps = getattr(site, "force_https", 0) or 0
|
||||
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
||||
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
||||
content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects)
|
||||
write_file(conf_path, content)
|
||||
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Site updated"}
|
||||
|
||||
|
||||
def _vhost_path(site_name: str) -> tuple[str, str]:
|
||||
"""Return (conf_path, disabled_path) for site vhost."""
|
||||
cfg = get_runtime_config()
|
||||
vhost_dir = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||
disabled_dir = os.path.join(vhost_dir, "disabled")
|
||||
return (
|
||||
os.path.join(vhost_dir, f"{site_name}.conf"),
|
||||
os.path.join(disabled_dir, f"{site_name}.conf"),
|
||||
)
|
||||
|
||||
|
||||
async def set_site_status(db: AsyncSession, site_id: int, status: int) -> dict:
|
||||
"""Enable (1) or disable (0) site by moving vhost config."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
|
||||
conf_path, disabled_path = _vhost_path(site.name)
|
||||
disabled_dir = os.path.dirname(disabled_path)
|
||||
|
||||
if status == 1: # enable
|
||||
if os.path.isfile(disabled_path):
|
||||
os.makedirs(os.path.dirname(conf_path), exist_ok=True)
|
||||
os.rename(disabled_path, conf_path)
|
||||
else: # disable
|
||||
if os.path.isfile(conf_path):
|
||||
os.makedirs(disabled_dir, exist_ok=True)
|
||||
os.rename(conf_path, disabled_path)
|
||||
|
||||
site.status = status
|
||||
await db.commit()
|
||||
|
||||
nginx_bin = os.path.join(get_runtime_config()["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
|
||||
return {"status": True, "msg": "Site " + ("enabled" if status == 1 else "disabled")}
|
||||
|
||||
|
||||
async def regenerate_site_vhost(db: AsyncSession, site_id: int) -> dict:
|
||||
"""Regenerate nginx vhost for a site (e.g. after redirect changes)."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
cfg = get_runtime_config()
|
||||
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
||||
if site.status != 1:
|
||||
return {"status": True, "msg": "Site disabled, vhost not active"}
|
||||
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
||||
if not os.path.exists(template_path):
|
||||
return {"status": False, "msg": "Template not found"}
|
||||
template = read_file(template_path) or ""
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
||||
domain_rows = domain_result.scalars().all()
|
||||
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domain_rows]
|
||||
server_names = " ".join(d.split(":")[0] for d in domain_list) if domain_list else site.name
|
||||
php_ver = getattr(site, "php_version", None) or "74"
|
||||
fhttps = getattr(site, "force_https", 0) or 0
|
||||
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
||||
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
||||
content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects)
|
||||
write_file(conf_path, content)
|
||||
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
return {"status": True, "msg": "Vhost regenerated"}
|
||||
Reference in New Issue
Block a user