diff --git a/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc b/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc index 1ed45c60..dbeb6e8b 100644 Binary files a/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc and b/YakPanel-server/backend/app/services/__pycache__/site_service.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/services/site_service.py b/YakPanel-server/backend/app/services/site_service.py index 3013d613..2287690b 100644 --- a/YakPanel-server/backend/app/services/site_service.py +++ b/YakPanel-server/backend/app/services/site_service.py @@ -13,6 +13,75 @@ from app.core.utils import path_safe_check, write_file, read_file, exec_shell_sy 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//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 _render_vhost( template: str,