"""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, get_settings 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 _nginx_site_template_path() -> str | None: """ Resolve webserver/templates/nginx_site.conf. Order: YAKPANEL_NGINX_TEMPLATE env, repo root (parent of backend/), Settings.panel_path. """ candidates: list[str] = [] env_override = (os.environ.get("YAKPANEL_NGINX_TEMPLATE") or "").strip() if env_override: candidates.append(env_override) # site_service.py -> services -> app -> backend -> YakPanel-server (repo root) here = os.path.abspath(__file__) repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here)))) candidates.append(os.path.join(repo_root, "webserver", "templates", "nginx_site.conf")) try: s = get_settings() pp = (s.panel_path or "").strip() if pp: candidates.append(os.path.join(os.path.abspath(pp), "webserver", "templates", "nginx_site.conf")) sp = (s.setup_path or "").strip() if sp: candidates.append( os.path.join(os.path.abspath(sp), "YakPanel-server", "webserver", "templates", "nginx_site.conf") ) except Exception: pass for path in candidates: if path and os.path.isfile(path): return path return None 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//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" root {root_path};\n" f' default_type "text/plain";\n' f" allow all;\n" f" access_log off;\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") template_path = _nginx_site_template_path() if 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 or before LE validation).""" 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() 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 template_path = _nginx_site_template_path() if not template_path: return { "status": False, "msg": "Template not found (nginx_site.conf). Expected under panel webserver/templates/ " "or set env YAKPANEL_NGINX_TEMPLATE to the full path. Check Settings.panel_path matches the install directory.", } 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(write_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"}