diff --git a/packages/secubox-vhost/api/main.py b/packages/secubox-vhost/api/main.py
index 41a90ad0..c67c4d58 100644
--- a/packages/secubox-vhost/api/main.py
+++ b/packages/secubox-vhost/api/main.py
@@ -11,6 +11,8 @@ import subprocess
import os
import json
import re
+import asyncio
+import time
from pathlib import Path
from typing import Optional, List, Dict, Any
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks
@@ -26,6 +28,17 @@ config = get_config("vhost")
NGINX_VHOST_DIR = Path(config.get("nginx_vhost_dir", "/etc/nginx/sites-available") if config else "/etc/nginx/sites-available")
NGINX_ENABLED_DIR = Path(config.get("nginx_enabled_dir", "/etc/nginx/sites-enabled") if config else "/etc/nginx/sites-enabled")
ACME_DIR = Path(config.get("acme_dir", "/etc/acme") if config else "/etc/acme")
+# The real cert store is the combined HAProxy PEMs (same source the certs module
+# reads); the legacy acme.sh /etc/acme layout is empty on this platform.
+CERTS_PEM_DIR = Path(config.get("certs_pem_dir", "/data/haproxy/certs")
+ if config else "/data/haproxy/certs")
+
+
+def _pem_cert_count() -> int:
+ try:
+ return len(list(CERTS_PEM_DIR.glob("*.pem"))) if CERTS_PEM_DIR.exists() else 0
+ except Exception:
+ return 0
DATA_PATH = Path(config.get("data_path", "/srv/vhost") if config else "/srv/vhost")
@@ -81,9 +94,7 @@ async def status():
enabled_count = len([f for f in NGINX_ENABLED_DIR.glob("*.conf") if f.is_symlink() or f.is_file()])
# Count certificates
- cert_count = 0
- if ACME_DIR.exists():
- cert_count = len([d for d in ACME_DIR.iterdir() if d.is_dir() and (d / "fullchain.cer").exists()])
+ cert_count = _pem_cert_count()
return {
"module": "vhost",
@@ -134,9 +145,7 @@ async def get_components():
if NGINX_VHOST_DIR.exists():
vhost_count = len(list(NGINX_VHOST_DIR.glob("*.conf")))
- cert_count = 0
- if ACME_DIR.exists():
- cert_count = len([d for d in ACME_DIR.iterdir() if d.is_dir() and (d / "fullchain.cer").exists()])
+ cert_count = _pem_cert_count()
return {
"components": [
@@ -593,41 +602,69 @@ async def delete_vhost(domain: str):
@app.get("/certificates")
async def list_certificates():
- """List all certificates (public)"""
- certs = []
-
- if ACME_DIR.exists():
- for cert_dir in ACME_DIR.iterdir():
- if not cert_dir.is_dir():
- continue
- cert_file = cert_dir / "fullchain.cer"
- if not cert_file.exists():
- continue
-
- domain = cert_dir.name
- expires = ""
- issuer = ""
-
- success, out, _ = run_cmd([
- "openssl", "x509", "-in", str(cert_file), "-noout", "-enddate", "-issuer"
- ])
- if success:
- for line in out.split("\n"):
- if "notAfter" in line:
- expires = line.split("=")[-1]
- if "issuer" in line.lower():
- issuer = line.split("=", 1)[-1]
-
- certs.append({
- "domain": domain,
- "expires": expires,
- "issuer": issuer,
- "cert_file": str(cert_file),
- })
-
+ """List all certificates from the combined HAProxy PEM store (the real cert
+ source, same as the certs module). Parsing shells out to openssl once per
+ PEM β dozens of blocking subprocesses β so it runs OFF the event loop and is
+ cached; otherwise it would freeze the shared aggregator loop (board-wide)."""
+ now = time.time()
+ if _CERT_CACHE["data"] is not None and now - _CERT_CACHE["ts"] < 60:
+ certs = _CERT_CACHE["data"]
+ else:
+ certs = await asyncio.to_thread(_read_pem_certs)
+ _CERT_CACHE.update(ts=now, data=certs)
return {"certificates": certs, "count": len(certs)}
+_CERT_CACHE: dict = {"ts": 0.0, "data": None}
+
+
+def _read_pem_certs() -> list:
+ """Blocking: parse every PEM in the store IN-PROCESS via cryptography (no
+ per-cert subprocess β dozens of openssl spawns would exhaust the box). Run
+ via to_thread + cached by the caller."""
+ certs = []
+ if not CERTS_PEM_DIR.exists():
+ return certs
+ try:
+ from cryptography import x509
+ except Exception:
+ x509 = None
+ for cert_file in sorted(CERTS_PEM_DIR.glob("*.pem")):
+ stem = cert_file.stem
+ # _wildcard_.example.com.pem -> *.example.com
+ domain = (f"*.{stem[len('_wildcard_.'):]}"
+ if stem.startswith("_wildcard_.") else stem)
+ expires = ""
+ issuer = ""
+ try:
+ data = cert_file.read_bytes()
+ leaf = None
+ if x509 is not None:
+ # combined PEM = key + leaf + chain; take the first certificate.
+ if hasattr(x509, "load_pem_x509_certificates"):
+ parsed = x509.load_pem_x509_certificates(data)
+ leaf = parsed[0] if parsed else None
+ else:
+ leaf = x509.load_pem_x509_certificate(data)
+ if leaf is not None:
+ dt = getattr(leaf, "not_valid_after_utc", None) or leaf.not_valid_after
+ # match the OpenSSL notAfter format the webui's new Date() parses
+ expires = dt.strftime("%b %e %H:%M:%S %Y GMT").replace(" ", " ")
+ try:
+ issuer = leaf.issuer.rfc4514_string()
+ except Exception:
+ issuer = ""
+ except Exception:
+ pass
+ certs.append({
+ "domain": domain,
+ "expires": expires,
+ "issuer": issuer,
+ "cert_file": str(cert_file),
+ })
+ return certs
+
+
class CertRequest(BaseModel):
domain: str
email: str
diff --git a/packages/secubox-vhost/www/vhost/index.html b/packages/secubox-vhost/www/vhost/index.html
index 59e951ae..7778a70a 100644
--- a/packages/secubox-vhost/www/vhost/index.html
+++ b/packages/secubox-vhost/www/vhost/index.html
@@ -6,318 +6,148 @@
SecuBox - VHost Manager
-
-
-
+
-
+