diff --git a/YakPanel-server/backend/app/api/__pycache__/files.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/files.cpython-314.pyc index b0cf38e5..7d1ff311 100644 Binary files a/YakPanel-server/backend/app/api/__pycache__/files.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/files.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/api/__pycache__/logs.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/logs.cpython-314.pyc index cd885d1c..a846d498 100644 Binary files a/YakPanel-server/backend/app/api/__pycache__/logs.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/logs.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc index 08a1d1c1..cb856c85 100644 Binary files a/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/site.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc b/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc index c69cef0f..ce1ca25c 100644 Binary files a/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc and b/YakPanel-server/backend/app/api/__pycache__/ssl.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/api/ssl.py b/YakPanel-server/backend/app/api/ssl.py index 53931dd6..178ecfea 100644 --- a/YakPanel-server/backend/app/api/ssl.py +++ b/YakPanel-server/backend/app/api/ssl.py @@ -1,5 +1,6 @@ """YakPanel - SSL/Domains API - Let's Encrypt via certbot""" import os +import re import shutil import subprocess import sys @@ -11,11 +12,10 @@ from typing import Optional from app.core.database import get_db from app.core.config import get_runtime_config -from app.core.utils import environment_with_system_path +from app.core.utils import environment_with_system_path, read_file, nginx_reload_all_known, nginx_binary_candidates from app.api.auth import get_current_user from app.models.user import User from app.models.site import Site, Domain -from app.core.utils import exec_shell_sync from app.services.site_service import regenerate_site_vhost router = APIRouter(prefix="/ssl", tags=["ssl"]) @@ -120,25 +120,9 @@ async def _le_hostnames_for_domain_row(db: AsyncSession, dom_row: Optional[Domai return out if out else ([primary] if primary else []) -def _reload_panel_and_common_nginx() -> None: +def _reload_panel_and_common_nginx() -> tuple[bool, str]: """Reload nginx so new vhost (ACME path) is live before certbot HTTP-01.""" - cfg = get_runtime_config() - seen: set[str] = set() - binaries: list[str] = [] - panel_ngx = os.path.join(cfg.get("setup_path") or "", "nginx", "sbin", "nginx") - if os.path.isfile(panel_ngx): - binaries.append(panel_ngx) - seen.add(os.path.realpath(panel_ngx)) - for alt in ("/usr/sbin/nginx", "/usr/bin/nginx", "/usr/local/nginx/sbin/nginx"): - if not os.path.isfile(alt): - continue - rp = os.path.realpath(alt) - if rp in seen: - continue - binaries.append(alt) - seen.add(rp) - for ngx in binaries: - exec_shell_sync(f'"{ngx}" -t && "{ngx}" -s reload', timeout=60) + return nginx_reload_all_known(timeout=60) @router.get("/domains") @@ -199,7 +183,12 @@ async def ssl_request_cert( status_code=500, detail="Cannot refresh nginx vhost before certificate request: " + str(regen_pre.get("msg", "")), ) - _reload_panel_and_common_nginx() + ok_ngx, err_ngx = _reload_panel_and_common_nginx() + if not ok_ngx: + raise HTTPException( + status_code=500, + detail="Nginx test/reload failed before certificate request (fix config, then retry): " + err_ngx, + ) challenge_dir = os.path.join(webroot_norm, ".well-known", "acme-challenge") try: @@ -278,6 +267,118 @@ async def ssl_request_cert( } +@router.get("/diagnostics") +async def ssl_diagnostics(current_user: User = Depends(get_current_user)): + """ + Help debug HTTP vs HTTPS: compares panel-written vhosts with what nginx -T actually loads. + ERR_CONNECTION_REFUSED on 443 usually means no listen 443 in the active nginx, or a firewall. + """ + cfg = get_runtime_config() + setup_abs = os.path.abspath((cfg.get("setup_path") or "").strip() or ".") + vhost_dir = os.path.join(setup_abs, "panel", "vhost", "nginx") + include_snippet = "include " + vhost_dir.replace(os.sep, "/") + "/*.conf;" + + vhost_summaries: list[dict] = [] + if os.path.isdir(vhost_dir): + try: + names = sorted(os.listdir(vhost_dir)) + except OSError: + names = [] + for fn in names: + if not fn.endswith(".conf") or fn.startswith("."): + continue + fp = os.path.join(vhost_dir, fn) + if not os.path.isfile(fp): + continue + body = read_file(fp) or "" + vhost_summaries.append({ + "file": fn, + "has_listen_80": bool(re.search(r"\blisten\s+80\b", body)), + "has_listen_443": bool(re.search(r"\blisten\s+.*443", body)), + "has_ssl_directives": "ssl_certificate" in body, + }) + + any_vhost_443 = any( + v.get("has_listen_443") and v.get("has_ssl_directives") for v in vhost_summaries + ) + effective_listen_443 = False + panel_include_in_effective_config = False + nginx_t_errors: list[str] = [] + norm_vhost = vhost_dir.replace(os.sep, "/") + env = environment_with_system_path() + + for ngx in nginx_binary_candidates(): + try: + r = subprocess.run( + [ngx, "-T"], + capture_output=True, + text=True, + timeout=25, + env=env, + ) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as e: + nginx_t_errors.append(f"{ngx}: {e}") + continue + dump = (r.stdout or "") + (r.stderr or "") + if r.returncode != 0: + nginx_t_errors.append(f"{ngx}: " + (dump.strip()[:800] or f"-T exit {r.returncode}")) + continue + if re.search(r"\blisten\s+.*443", dump): + effective_listen_443 = True + if norm_vhost in dump or "panel/vhost/nginx" in dump: + panel_include_in_effective_config = True + + hints: list[str] = [] + if not os.path.isdir(vhost_dir): + hints.append(f"The panel vhost directory is missing ({vhost_dir}). Create a website in YakPanel first.") + elif not vhost_summaries: + hints.append("There are no .conf files under the panel nginx vhost directory.") + + le_live = "/etc/letsencrypt/live" + le_present = False + if os.path.isdir(le_live): + try: + le_present = any( + n and not n.startswith(".") + for n in os.listdir(le_live) + ) + except OSError: + le_present = False + + if le_present and vhost_summaries and not any_vhost_443: + hints.append( + "Let's Encrypt certs exist on this server but panel vhosts do not include an HTTPS (listen 443 ssl) block. " + "Regenerate the vhost: edit the site and save, or use Request SSL again." + ) + + if any_vhost_443 and not effective_listen_443: + hints.append( + "Your panel .conf files define HTTPS, but nginx -T does not show any listen 443 — the daemon that handles traffic is not loading YakPanel vhosts. " + "Add the include line below inside http { } for that nginx (e.g. /etc/nginx/nginx.conf), then nginx -t && reload." + ) + elif vhost_summaries and not panel_include_in_effective_config: + hints.append( + "If http://domain shows the default 'Welcome to nginx' page, stock nginx is answering and likely does not include YakPanel vhosts. " + "Add the include below (or symlink this directory into /etc/nginx/conf.d/)." + ) + + if effective_listen_443: + hints.append( + "Loaded nginx configuration includes a 443 listener. If HTTPS still fails, open TCP port 443 on the OS firewall and cloud/VPS security group." + ) + + return { + "vhost_dir": vhost_dir, + "include_snippet": include_snippet, + "vhosts": vhost_summaries, + "any_vhost_listen_ssl": any_vhost_443, + "nginx_effective_listen_443": effective_listen_443, + "panel_vhost_path_in_nginx_t": panel_include_in_effective_config, + "nginx_t_probe_errors": nginx_t_errors, + "hints": hints, + } + + @router.get("/certificates") async def ssl_list_certificates(current_user: User = Depends(get_current_user)): """List existing Let's Encrypt certificates""" diff --git a/YakPanel-server/backend/app/core/__pycache__/utils.cpython-314.pyc b/YakPanel-server/backend/app/core/__pycache__/utils.cpython-314.pyc index ee5fcc94..0e15d6a9 100644 Binary files a/YakPanel-server/backend/app/core/__pycache__/utils.cpython-314.pyc and b/YakPanel-server/backend/app/core/__pycache__/utils.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/core/utils.py b/YakPanel-server/backend/app/core/utils.py index dc92b065..53bad7eb 100644 --- a/YakPanel-server/backend/app/core/utils.py +++ b/YakPanel-server/backend/app/core/utils.py @@ -130,3 +130,86 @@ def exec_shell_sync(cmd: str, timeout: Optional[float] = None, cwd: Optional[str return "", "Timed out" except Exception as e: return "", str(e) + + +def nginx_test_and_reload(nginx_bin: str, timeout: float = 60.0) -> Tuple[bool, str]: + """Run ``nginx -t`` then ``nginx -s reload``. Returns (success, error_message).""" + if not nginx_bin or not os.path.isfile(nginx_bin): + return True, "" + env = environment_with_system_path() + try: + t = subprocess.run( + [nginx_bin, "-t"], + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as e: + return False, str(e) + if t.returncode != 0: + err = (t.stderr or t.stdout or "").strip() + return False, err or f"nginx -t exited {t.returncode}" + try: + r = subprocess.run( + [nginx_bin, "-s", "reload"], + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as e: + return False, str(e) + if r.returncode != 0: + err = (r.stderr or r.stdout or "").strip() + return False, err or f"nginx -s reload exited {r.returncode}" + return True, "" + + +def nginx_binary_candidates() -> list[str]: + """Nginx binaries to operate on: panel-bundled first, then common system paths (deduped by realpath).""" + from app.core.config import get_runtime_config + + cfg = get_runtime_config() + seen: set[str] = set() + binaries: list[str] = [] + panel_ngx = os.path.join(cfg.get("setup_path") or "", "nginx", "sbin", "nginx") + if os.path.isfile(panel_ngx): + binaries.append(panel_ngx) + try: + seen.add(os.path.realpath(panel_ngx)) + except OSError: + seen.add(panel_ngx) + for alt in ("/usr/sbin/nginx", "/usr/bin/nginx", "/usr/local/nginx/sbin/nginx"): + if not os.path.isfile(alt): + continue + try: + rp = os.path.realpath(alt) + except OSError: + rp = alt + if rp in seen: + continue + binaries.append(alt) + seen.add(rp) + return binaries + + +def nginx_reload_all_known(timeout: float = 60.0) -> Tuple[bool, str]: + """ + Test and reload panel nginx (setup_path/nginx/sbin/nginx) and distinct system nginx + binaries so vhost changes apply regardless of which daemon serves sites. + """ + binaries = nginx_binary_candidates() + if not binaries: + return True, "" + errs: list[str] = [] + ok_any = False + for ngx in binaries: + ok, err = nginx_test_and_reload(ngx, timeout=timeout) + if ok: + ok_any = True + else: + errs.append(f"{ngx}: {err}") + if ok_any: + return True, "" + return False, "; ".join(errs) if errs else "nginx reload failed for all candidates" diff --git a/YakPanel-server/backend/app/services/__pycache__/config_service.cpython-314.pyc b/YakPanel-server/backend/app/services/__pycache__/config_service.cpython-314.pyc new file mode 100644 index 00000000..91173071 Binary files /dev/null and b/YakPanel-server/backend/app/services/__pycache__/config_service.cpython-314.pyc differ 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 657ff2c0..9a732737 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 b10fc10d..4bc09fb1 100644 --- a/YakPanel-server/backend/app/services/site_service.py +++ b/YakPanel-server/backend/app/services/site_service.py @@ -8,13 +8,55 @@ 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 +from app.core.utils import path_safe_check, write_file, read_file, exec_shell_sync, nginx_reload_all_known 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 +_SAN_CACHE: dict[str, tuple[float, frozenset[str]]] = {} + + +def _normalize_hostname(h: str) -> str: + return (h or "").strip().lower().split(":")[0] + + +def _iter_le_pairs_sorted() -> list[tuple[str, str]]: + if not os.path.isdir(LETSENCRYPT_LIVE): + return [] + try: + names = sorted(os.listdir(LETSENCRYPT_LIVE)) + except OSError: + return [] + out: list[tuple[str, str]] = [] + for entry in names: + if entry.startswith(".") or ".." in entry: + continue + fc = os.path.join(LETSENCRYPT_LIVE, entry, "fullchain.pem") + pk = os.path.join(LETSENCRYPT_LIVE, entry, "privkey.pem") + if os.path.isfile(fc) and os.path.isfile(pk): + out.append((fc, pk)) + return out + + +def _cert_san_names(fullchain: str) -> frozenset[str]: + try: + st = os.stat(fullchain) + mtime = st.st_mtime + except OSError: + return frozenset() + hit = _SAN_CACHE.get(fullchain) + if hit is not None and hit[0] == mtime: + return hit[1] + out, _err = exec_shell_sync(f'openssl x509 -in "{fullchain}" -noout -text', timeout=8) + names: set[str] = set() + if out: + for m in re.finditer(r"DNS:([^,\s\n]+)", out, flags=re.IGNORECASE): + names.add(m.group(1).strip().lower()) + froz = frozenset(names) + _SAN_CACHE[fullchain] = (mtime, froz) + return froz def _nginx_site_template_path() -> str | None: @@ -77,30 +119,40 @@ def _parse_cert_not_after(cert_path: str) -> datetime | None: def _best_ssl_for_hostnames(hostnames: list[str]) -> dict: - """Match LE certs by live//fullchain.pem; pick longest validity.""" + """Pick the LE cert (live/ or SAN) that covers site hostnames with longest validity.""" none = {"status": "none", "days_left": None, "cert_name": None} - live_root = LETSENCRYPT_LIVE + seen: set[str] = set() + want_list: list[str] = [] + for host in hostnames: + n = _normalize_hostname(host) + if n and ".." not in n and n not in seen: + seen.add(n) + want_list.append(n) + if not want_list: + return none + want = set(want_list) try: - if not os.path.isdir(live_root): + if not os.path.isdir(LETSENCRYPT_LIVE): 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: + for fc, _pk in _iter_le_pairs_sorted(): + live_name = os.path.basename(os.path.dirname(fc)).lower() + if live_name in want: + match_names = {live_name} + else: + match_names = want & _cert_san_names(fc) + if not match_names: 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) + end = _parse_cert_not_after(fc) if end is None: continue now = datetime.now(timezone.utc) days = int((end - now).total_seconds() // 86400) + pick = min(match_names) if best_days is None or days > best_days: best_days = days - best_name = h + best_name = pick if best_days is None: return none if best_days < 0: @@ -127,6 +179,31 @@ def _letsencrypt_paths(hostname: str) -> tuple[str, str] | None: return None +def _letsencrypt_paths_any(hostnames: list[str]) -> tuple[str, str] | None: + """First matching LE cert: exact live//, then live dir name, then SAN match.""" + seen: set[str] = set() + want_ordered: list[str] = [] + for h in hostnames: + n = _normalize_hostname(h) + if n and ".." not in n and n not in seen: + seen.add(n) + want_ordered.append(n) + if not want_ordered: + return None + want = set(want_ordered) + for n in want_ordered: + p = _letsencrypt_paths(n) + if p: + return p + for fc, pk in _iter_le_pairs_sorted(): + live_name = os.path.basename(os.path.dirname(fc)).lower() + if live_name in want: + return fc, pk + if want & _cert_san_names(fc): + return fc, pk + return None + + def _build_ssl_server_block( server_names: str, root_path: str, @@ -307,13 +384,16 @@ async def create_site( ) 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") + reload_ok, reload_err = nginx_reload_all_known() await db.commit() - return {"status": True, "msg": "Site created", "id": site.id} + if reload_ok: + return {"status": True, "msg": "Site created", "id": site.id} + return { + "status": True, + "msg": f"Site created but nginx reload failed (HTTPS may not work): {reload_err}", + "id": site.id, + } async def list_sites(db: AsyncSession) -> list[dict]: @@ -364,12 +444,12 @@ async def delete_site(db: AsyncSession, site_id: int) -> dict: 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") + reload_ok, reload_err = nginx_reload_all_known() await db.commit() - return {"status": True, "msg": "Site deleted"} + if reload_ok: + return {"status": True, "msg": "Site deleted"} + return {"status": True, "msg": f"Site deleted but nginx reload failed: {reload_err}"} async def get_site_count(db: AsyncSession) -> int: @@ -462,9 +542,10 @@ async def update_site( 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") + reload_ok, reload_err = nginx_reload_all_known() + if not reload_ok: + await db.commit() + return {"status": False, "msg": f"Vhost updated but nginx test/reload failed: {reload_err}"} await db.commit() return {"status": True, "msg": "Site updated"} @@ -503,9 +584,12 @@ async def set_site_status(db: AsyncSession, site_id: int, status: int) -> dict: 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") + reload_ok, reload_err = nginx_reload_all_known() + if not reload_ok: + return { + "status": False, + "msg": f"Site {'enabled' if status == 1 else 'disabled'} but nginx test/reload failed: {reload_err}", + } return {"status": True, "msg": "Site " + ("enabled" if status == 1 else "disabled")} @@ -543,7 +627,7 @@ async def regenerate_site_vhost(db: AsyncSession, site_id: int) -> dict: 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") + reload_ok, reload_err = nginx_reload_all_known() + if not reload_ok: + return {"status": False, "msg": f"Vhost written but nginx test/reload failed: {reload_err}"} return {"status": True, "msg": "Vhost regenerated"} diff --git a/YakPanel-server/backend/app/tasks/__pycache__/__init__.cpython-314.pyc b/YakPanel-server/backend/app/tasks/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 00000000..e86154e9 Binary files /dev/null and b/YakPanel-server/backend/app/tasks/__pycache__/__init__.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/tasks/__pycache__/celery_app.cpython-314.pyc b/YakPanel-server/backend/app/tasks/__pycache__/celery_app.cpython-314.pyc new file mode 100644 index 00000000..76ab0a4c Binary files /dev/null and b/YakPanel-server/backend/app/tasks/__pycache__/celery_app.cpython-314.pyc differ diff --git a/YakPanel-server/backend/app/tasks/__pycache__/install.cpython-314.pyc b/YakPanel-server/backend/app/tasks/__pycache__/install.cpython-314.pyc new file mode 100644 index 00000000..015cf9c8 Binary files /dev/null and b/YakPanel-server/backend/app/tasks/__pycache__/install.cpython-314.pyc differ diff --git a/YakPanel-server/frontend/src/pages/DomainsPage.tsx b/YakPanel-server/frontend/src/pages/DomainsPage.tsx index 595d5138..0ba52b68 100644 --- a/YakPanel-server/frontend/src/pages/DomainsPage.tsx +++ b/YakPanel-server/frontend/src/pages/DomainsPage.tsx @@ -17,9 +17,21 @@ interface Certificate { path: string } +interface SslDiagnostics { + vhost_dir: string + include_snippet: string + vhosts: { file: string; has_listen_80: boolean; has_listen_443: boolean; has_ssl_directives: boolean }[] + any_vhost_listen_ssl: boolean + nginx_effective_listen_443: boolean + panel_vhost_path_in_nginx_t: boolean + nginx_t_probe_errors: string[] + hints: string[] +} + export function DomainsPage() { const [domains, setDomains] = useState([]) const [certificates, setCertificates] = useState([]) + const [diag, setDiag] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [requesting, setRequesting] = useState(null) @@ -86,6 +98,34 @@ export function DomainsPage() { Request Let's Encrypt certificates for your site domains. Requires certbot and nginx configured for the domain. + {diag ? ( +
+
HTTPS / nginx check
+ {diag.hints.length ? ( +
    + {diag.hints.map((h, i) => ( +
  • + {h} +
  • + ))} +
+ ) : null} +
Include YakPanel vhosts inside the http block of the nginx process that serves your sites:
+ {diag.include_snippet} + {diag.vhosts.length > 0 ? ( +
+ Panel configs scanned:{' '} + {diag.vhosts.map((v) => `${v.file}${v.has_listen_443 && v.has_ssl_directives ? ' (HTTPS block)' : ''}`).join(', ')} +
+ ) : null} + {diag.nginx_t_probe_errors.length > 0 ? ( +
+ nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')} +
+ ) : null} +
+ ) : null} +