Files
yakpanel-core/YakPanel-server/backend/app/services/site_service.py
2026-04-07 10:23:05 +05:30

515 lines
20 KiB
Python

"""YakPanel - Site service"""
import os
import re
from datetime import datetime, timezone
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})$")
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
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"
f' default_type "text/plain";\n'
f" allow all;\n"
f" try_files $uri =404;\n"
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"
)
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,
le_hostnames: list[str] | None = None,
) -> str:
"""Render nginx vhost template. redirects: [(source, target, code), ...]"""
if force_https:
force_block = (
' if ($request_uri !~ "^/.well-known/acme-challenge/") {\n'
" return 301 https://$host$request_uri;\n"
" }"
)
else:
force_block = ""
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 ""
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
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)
content = content.replace("{SSL_SERVER_BLOCK}", ssl_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)
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
)
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, primary domain, backup count, SSL summary."""
cfg = get_runtime_config()
backup_dir = cfg.get("backup_path") or ""
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).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"
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(domain_rows),
"addtime": s.addtime.isoformat() if s.addtime else None,
"php_version": php_ver,
"primary_domain": primary,
"domains": domain_list,
"backup_count": _backup_count(s.name, backup_dir),
"ssl": _best_ssl_for_hostnames(hostnames),
})
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()]
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
)
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()]
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
)
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"}