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

@@ -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()
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"}