new changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user