Files
yakpanel-core/YakPanel-server/backend/app/services/site_service.py

517 lines
20 KiB
Python
Raw Normal View History

2026-04-07 02:04:22 +05:30
"""YakPanel - Site service"""
import os
import re
2026-04-07 10:03:25 +05:30
from datetime import datetime, timezone
2026-04-07 02:04:22 +05:30
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})$")
2026-04-07 10:12:30 +05:30
LETSENCRYPT_LIVE = "/etc/letsencrypt/live"
SSL_EXPIRING_DAYS = 14
def _backup_count(site_name: str, backup_dir: str) -> int:
if not backup_dir or not os.path.isdir(backup_dir):
return 0
prefix = f"{site_name}_"
n = 0
try:
for f in os.listdir(backup_dir):
if f.startswith(prefix) and f.endswith(".tar.gz"):
n += 1
except OSError:
return 0
return n
def _parse_cert_not_after(cert_path: str) -> datetime | None:
if not os.path.isfile(cert_path):
return None
out, _err = exec_shell_sync(f'openssl x509 -in "{cert_path}" -noout -enddate', timeout=5)
if not out or "notAfter=" not in out:
return None
val = out.strip().split("=", 1)[1].strip()
try:
dt = datetime.strptime(val, "%b %d %H:%M:%S %Y GMT")
return dt.replace(tzinfo=timezone.utc)
except ValueError:
return None
def _best_ssl_for_hostnames(hostnames: list[str]) -> dict:
"""Match LE certs by live/<domain>/fullchain.pem; pick longest validity."""
none = {"status": "none", "days_left": None, "cert_name": None}
live_root = LETSENCRYPT_LIVE
try:
if not os.path.isdir(live_root):
return none
best_days: int | None = None
best_name: str | None = None
for host in hostnames:
h = (host or "").split(":")[0].strip().lower()
if not h:
continue
folder = os.path.join(live_root, h)
if not os.path.isdir(folder):
continue
fullchain = os.path.join(folder, "fullchain.pem")
end = _parse_cert_not_after(fullchain)
if end is None:
continue
now = datetime.now(timezone.utc)
days = int((end - now).total_seconds() // 86400)
if best_days is None or days > best_days:
best_days = days
best_name = h
if best_days is None:
return none
if best_days < 0:
status = "expired"
elif best_days <= SSL_EXPIRING_DAYS:
status = "expiring"
else:
status = "active"
return {"status": status, "days_left": best_days, "cert_name": best_name}
except OSError:
return none
2026-04-07 02:04:22 +05:30
2026-04-07 10:23:05 +05:30
def _letsencrypt_paths(hostname: str) -> tuple[str, str] | None:
"""Return (fullchain, privkey) if Let's Encrypt files exist for this hostname."""
h = (hostname or "").strip().lower().split(":")[0]
if not h or ".." in h:
return None
base = os.path.join(LETSENCRYPT_LIVE, h)
fc = os.path.join(base, "fullchain.pem")
pk = os.path.join(base, "privkey.pem")
if os.path.isfile(fc) and os.path.isfile(pk):
return fc, pk
return None
def _build_ssl_server_block(
server_names: str,
root_path: str,
logs_path: str,
site_name: str,
php_version: str,
fullchain: str,
privkey: str,
redirects: list[tuple[str, str, int]] | None,
) -> str:
"""Second server {} for HTTPS when LE certs exist."""
pv = php_version or "74"
redirect_lines: list[str] = []
for src, tgt, code in (redirects or []):
if src and tgt:
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
redirect_block = ("\n" + "\n".join(redirect_lines)) if redirect_lines else ""
q_fc = fullchain.replace("\\", "\\\\").replace('"', '\\"')
q_pk = privkey.replace("\\", "\\\\").replace('"', '\\"')
return (
f"server {{\n"
f" listen 443 ssl;\n"
f" server_name {server_names};\n"
f' ssl_certificate "{q_fc}";\n'
f' ssl_certificate_key "{q_pk}";\n'
f" index index.php index.html index.htm default.php default.htm default.html;\n"
f" root {root_path};\n"
f" error_page 404 /404.html;\n"
f" error_page 502 /502.html;\n"
f" location ^~ /.well-known/acme-challenge/ {{\n"
2026-04-07 10:35:44 +05:30
f" root {root_path};\n"
2026-04-07 10:23:05 +05:30
f' default_type "text/plain";\n'
f" allow all;\n"
2026-04-07 10:35:44 +05:30
f" access_log off;\n"
2026-04-07 10:23:05 +05:30
f" }}\n"
f"{redirect_block}\n"
r" location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {" + "\n"
f" expires 30d;\n"
f" access_log off;\n"
f" }}\n"
r" location ~ .*\.(js|css)?$ {" + "\n"
f" expires 12h;\n"
f" access_log off;\n"
f" }}\n"
r" location ~ \.php$ {" + "\n"
f" fastcgi_pass unix:/tmp/php-cgi-{pv}.sock;\n"
f" fastcgi_index index.php;\n"
f" include fastcgi.conf;\n"
f" }}\n"
f" access_log {logs_path}/{site_name}.log;\n"
f" error_log {logs_path}/{site_name}.error.log;\n"
f"}}\n"
)
2026-04-07 02:04:22 +05:30
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,
2026-04-07 10:23:05 +05:30
le_hostnames: list[str] | None = None,
2026-04-07 02:04:22 +05:30
) -> str:
"""Render nginx vhost template. redirects: [(source, target, code), ...]"""
2026-04-07 10:23:05 +05:30
if force_https:
force_block = (
' if ($request_uri !~ "^/.well-known/acme-challenge/") {\n'
" return 301 https://$host$request_uri;\n"
" }"
)
else:
force_block = ""
2026-04-07 02:04:22 +05:30
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 ""
2026-04-07 10:23:05 +05:30
hosts = le_hostnames if le_hostnames is not None else [p for p in server_names.split() if p]
ssl_block = ""
for h in hosts:
le = _letsencrypt_paths(h)
if le:
fc, pk = le
ssl_block = _build_ssl_server_block(
server_names, root_path, logs_path, site_name, php_version, fc, pk, redirects
)
break
2026-04-07 02:04:22 +05:30
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)
2026-04-07 10:23:05 +05:30
content = content.replace("{SSL_SERVER_BLOCK}", ssl_block)
2026-04-07 02:04:22 +05:30
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)
2026-04-07 10:23:05 +05:30
le_hosts = [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, [], le_hosts
)
2026-04-07 02:04:22 +05:30
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]:
2026-04-07 10:03:25 +05:30
"""List all sites with domain count, primary domain, backup count, SSL summary."""
cfg = get_runtime_config()
backup_dir = cfg.get("backup_path") or ""
2026-04-07 02:04:22 +05:30
result = await db.execute(select(Site).order_by(Site.id))
sites = result.scalars().all()
out = []
for s in sites:
2026-04-07 10:03:25 +05:30
domain_result = await db.execute(select(Domain).where(Domain.pid == s.id).order_by(Domain.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]
hostnames = [d.name for d in domain_rows]
primary = hostnames[0] if hostnames else ""
php_ver = getattr(s, "php_version", None) or "74"
2026-04-07 02:04:22 +05:30
out.append({
"id": s.id,
"name": s.name,
"path": s.path,
"status": s.status,
"ps": s.ps,
"project_type": s.project_type,
2026-04-07 10:03:25 +05:30
"domain_count": len(domain_rows),
2026-04-07 02:04:22 +05:30
"addtime": s.addtime.isoformat() if s.addtime else None,
2026-04-07 10:03:25 +05:30
"php_version": php_ver,
"primary_domain": primary,
"domains": domain_list,
"backup_count": _backup_count(s.name, backup_dir),
"ssl": _best_ssl_for_hostnames(hostnames),
2026-04-07 02:04:22 +05:30
})
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()]
2026-04-07 10:23:05 +05:30
le_hosts = [d.name for d in domain_rows]
content = _render_vhost(
template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts
)
2026-04-07 02:04:22 +05:30
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:
2026-04-07 10:35:44 +05:30
"""Regenerate nginx vhost for a site (e.g. after redirect changes or before LE validation)."""
2026-04-07 02:04:22 +05:30
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()
2026-04-07 10:35:44 +05:30
conf_path, disabled_path = _vhost_path(site.name)
if site.status == 1:
write_path = conf_path
else:
write_path = disabled_path if os.path.isfile(disabled_path) else conf_path
2026-04-07 02:04:22 +05:30
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()]
2026-04-07 10:23:05 +05:30
le_hosts = [d.name for d in domain_rows]
content = _render_vhost(
template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects, le_hosts
)
2026-04-07 10:35:44 +05:30
write_file(write_path, content)
2026-04-07 02:04:22 +05:30
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"}