new changes

This commit is contained in:
Niranjan
2026-04-07 11:42:19 +05:30
parent cc45fac342
commit 10236a4cd4
14 changed files with 360 additions and 52 deletions

View File

@@ -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"""

View File

@@ -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"

View File

@@ -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/<domain>/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/<host>/, 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()
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()
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"}

View File

@@ -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<Domain[]>([])
const [certificates, setCertificates] = useState<Certificate[]>([])
const [diag, setDiag] = useState<SslDiagnostics | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [requesting, setRequesting] = useState<string | null>(null)
@@ -86,6 +98,34 @@ export function DomainsPage() {
Request Let&apos;s Encrypt certificates for your site domains. Requires certbot and nginx configured for the domain.
</div>
{diag ? (
<div className="alert alert-info small mb-4">
<div className="fw-semibold mb-2">HTTPS / nginx check</div>
{diag.hints.length ? (
<ul className="mb-3 ps-3">
{diag.hints.map((h, i) => (
<li key={i} className="mb-1">
{h}
</li>
))}
</ul>
) : null}
<div className="text-secondary mb-1">Include YakPanel vhosts inside the <code>http</code> block of the nginx process that serves your sites:</div>
<code className="d-block user-select-all bg-body-secondary p-2 rounded small text-break">{diag.include_snippet}</code>
{diag.vhosts.length > 0 ? (
<div className="mt-2 text-secondary">
Panel configs scanned:{' '}
{diag.vhosts.map((v) => `${v.file}${v.has_listen_443 && v.has_ssl_directives ? ' (HTTPS block)' : ''}`).join(', ')}
</div>
) : null}
{diag.nginx_t_probe_errors.length > 0 ? (
<div className="mt-2 small text-danger">
nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')}
</div>
) : null}
</div>
) : null}
<div className="row g-4">
<div className="col-lg-6">
<div className="card h-100">