diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py index 8f751b2..7024ba5 100644 --- a/daemon/handlers/acme.py +++ b/daemon/handlers/acme.py @@ -40,7 +40,9 @@ logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().parent.parent.parent _ACME_HOME = PROJECT_DIR / "data" / "acme" -_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh") +# acme.sh resolves deploy hooks from $ACME_HOME/deploy/ -- _findHook +# only searches the deploy subdirectory, never accepts absolute paths. +_DEPLOY_HOOK = "acme-deploy.sh" _ACME_ENVIRON = { "HOME": str(PROJECT_DIR), @@ -181,7 +183,7 @@ def _check_domain_format(domain: str) -> tuple[bool, str]: pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$" if not _re.match(pattern, domain): return False, "Invalid domain name format" - return True, "" + return True, "Domain format is valid" def _get_local_ips() -> set[str]: @@ -343,10 +345,12 @@ def _check_existing_cert(domain: str) -> tuple[bool, str]: try: days = lib.acme.days_until_expiry(domain) except (RuntimeError, FileNotFoundError): - return True, "" - if days is not None and days > 0: + return True, "No existing certificate found" + if days is None: + return True, "No existing certificate found" + if days > 0: return True, f"Valid certificate exists ({days} days remaining)" - return True, "" + return True, f"Certificate expired ({abs(days)} days ago)" def _check_nginx_running() -> tuple[bool, str]: @@ -497,7 +501,11 @@ def _check_port_80_listening() -> tuple[bool, str]: def _check_acme_account() -> tuple[bool, str]: - """Non-blocking: check acme.sh account is configured.""" + """Non-blocking: check acme.sh account is configured. + + Tries acme.sh --info first, then falls back to parsed account state + (handles both legacy .account.conf and modern declarative config). + """ try: acme_bin = _find_acme_bin() acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) @@ -520,14 +528,11 @@ def _check_acme_account() -> tuple[bool, str]: except (FileNotFoundError, subprocess.TimeoutExpired): pass - try: - account_conf = _ACME_HOME / ".account.conf" - if account_conf.is_file(): - text = account_conf.read_text() - if "ACME_LEEMAIL" in text and "ACME_MCA" in text: - return True, "ACME account is configured" - except OSError: - pass + from lib.state import _parse_account_conf + + info = _parse_account_conf(_ACME_HOME) + if info.get("registered"): + return True, "ACME account is configured" return ( False, @@ -538,17 +543,14 @@ def _check_acme_account() -> tuple[bool, str]: def _check_account_registered() -> tuple[bool, str]: """Blocking check: verify an ACME account is registered. - Reads the user-facing .account.conf (with leading dot) which stores - the registered account's ACME_LEEMAIL and ACME_MCA keys. + Delegates to ``lib.state._parse_account_conf()`` which checks both + the legacy .account.conf and the declarative config/acme/config.json + used by modern acme.sh (v3.x). """ - account_conf = _ACME_HOME / ".account.conf" - if not account_conf.is_file(): - return False, "Register an ACME account before issuing certificates" - try: - text = account_conf.read_text() - except OSError: - return False, "Register an ACME account before issuing certificates" - if "ACME_LEEMAIL" in text and "ACME_MCA" in text: + from lib.state import _parse_account_conf + + info = _parse_account_conf(_ACME_HOME) + if info.get("registered"): return True, "ACME account is registered" return False, "Register an ACME account before issuing certificates" @@ -571,6 +573,11 @@ def _check_dns_public(domain: str) -> tuple[bool, str]: if not local_ips: return True, "Public DNS check skipped (no local IPs detected)" + # Behind NAT: public DNS can never match local (private) IPs. + # dns_resolves already verified the domain correctly via external IP. + if all(_is_private_ip(ip) for ip in local_ips): + return True, "Public DNS check skipped (NAT detected — dns_resolves verified)" + for dns_server in ("8.8.8.8", "1.1.1.1"): try: result = subprocess.run( @@ -942,13 +949,14 @@ def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str] if not body or "domain" not in body: raise ValueError("'domain' is required") domain = body["domain"] - acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) - acme_home = str(Path(acme_home_env) / domain) + from lib.acme import find_cert_dir + + cert_dir = str(find_cert_dir(domain, _ACME_HOME)) return { - "cert": f"{acme_home}/{domain}.cert", - "key": f"{acme_home}/{domain}.key", - "ca": f"{acme_home}/ca.cer", - "fullchain": f"{acme_home}/fullchain.cer", + "cert": f"{cert_dir}/{domain}.cert", + "key": f"{cert_dir}/{domain}.key", + "ca": f"{cert_dir}/ca.cer", + "fullchain": f"{cert_dir}/fullchain.cer", } diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index fa63bd9..56d0c80 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -23,6 +23,7 @@ from daemon.iface import ( POST_NGINX_TEST, ) from daemon.server import NotFoundError, refresh_state, registry +from lib.acme import find_cert_dir from lib.common import ensure_dirs, load_json, run, run_proc, save_json logger = logging.getLogger(__name__) @@ -104,6 +105,8 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str: Rendered server block as a string. """ tmpl = ENV.get_template("nginx/server_block.conf") + acme_home_path = PROJECT_DIR / "data" / "acme" + acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path)) return tmpl.render( domain=domain_cfg["domain"], backend=domain_cfg.get("backend", {}), @@ -112,7 +115,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str: cert=domain_cfg.get("cert"), auth=domain_cfg.get("auth"), is_management=False, - acme_home=str(PROJECT_DIR / "data" / "acme"), + acme_cert_dir=acme_cert_dir, certs_dir=str(PROJECT_DIR / "data" / "certs"), acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), ) @@ -209,6 +212,8 @@ def _write_all_sites() -> None: if cfg.get("management"): mgmt = cfg["management"] tmpl = ENV.get_template("nginx/server_block.conf") + acme_home_path = PROJECT_DIR / "data" / "acme" + acme_cert_dir = str(find_cert_dir(mgmt.get("domain", ""), acme_home_path)) mgmt_conf = tmpl.render( domain=mgmt.get("domain"), backend=dict( @@ -219,7 +224,7 @@ def _write_all_sites() -> None: cert=None, auth=mgmt.get("auth"), is_management=True, - acme_home=str(PROJECT_DIR / "data" / "acme"), + acme_cert_dir=acme_cert_dir, certs_dir=str(PROJECT_DIR / "data" / "certs"), acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), ) diff --git a/install.sh b/install.sh index 5955f9b..c331de1 100755 --- a/install.sh +++ b/install.sh @@ -218,8 +218,12 @@ else log "acme.sh already installed." fi -# Ensure the acme deploy hook script has correct permissions -chmod 0755 "${PROJECT_DIR}/system/acme-deploy.sh" +# Install the deploy hook into acme.sh's deploy directory +# (acme.sh only resolves hooks from $ACME_HOME/deploy/) +mkdir -p "$ACME_HOME/deploy" +cp "${PROJECT_DIR}/system/acme-deploy.sh" "$ACME_HOME/deploy/acme-deploy.sh" +chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/deploy/acme-deploy.sh" +chmod 0755 "$ACME_HOME/deploy/acme-deploy.sh" # --- 3. Setup directories --- log "Creating config and data directories..." diff --git a/lib/acme.py b/lib/acme.py index 51116f8..8e633ea 100644 --- a/lib/acme.py +++ b/lib/acme.py @@ -18,7 +18,9 @@ logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().parent.parent _ACME_HOME = PROJECT_DIR / "data" / "acme" -_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh") +# acme.sh resolves deploy hooks from $ACME_HOME/deploy/ -- _findHook +# only searches the deploy subdirectory, never accepts absolute paths. +_DEPLOY_HOOK = "acme-deploy.sh" _ACME_ENVIRON = { "HOME": str(PROJECT_DIR), @@ -157,10 +159,12 @@ def _read_acme_email() -> str: except OSError as exc: logger.warning("Could not read account config: %s", exc) # Fallback: read from declarative ACME config + # Derive project root from acme_home (acme_home is at /data/acme). try: from lib.common import load_json - acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json" + project_root = acme_home.parent.parent + acme_cfg = project_root / "config" / "acme" / "config.json" conf = load_json(acme_cfg) if conf and "email" in conf: return conf["email"] @@ -257,10 +261,12 @@ def list_certs() -> list[dict]: continue san_domains = [ - d.strip() for d in entry.get("san_domains", "").split(",") if d.strip() + d.strip() + for d in entry.get("san_domains", "").split(",") + if d.strip() and d.strip().lower() != "no" ] - cert_dir = acme_home / main + cert_dir = find_cert_dir(main, acme_home) cert_path = str(cert_dir / "fullchain.cer") key_path = str(cert_dir / f"{main}.key") ca_path = str(cert_dir / "ca.cer") @@ -390,6 +396,34 @@ def copy_cert(domain: str, dest_dir: str) -> dict: } +def find_cert_dir(domain: str, acme_home: Path | None = None) -> Path: + """Find the certificate directory for a domain. + + acme.sh may name the directory {domain}/ (RSA) or {domain}_ecc/ (ECC). + Checks both and returns whichever exists. Falls back to {domain}/ if + neither exists (preserves original behaviour for forward compatibility). + + Args: + domain: The domain name. + acme_home: Override ACME home directory. Defaults to _ACME_HOME. + + Returns: + Path to the directory containing the cert files. + """ + if acme_home is None: + acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) + acme_home = Path(acme_home_env) + + ecc_dir = acme_home / f"{domain}_ecc" + rsa_dir = acme_home / domain + + if ecc_dir.is_dir(): + return ecc_dir + if rsa_dir.is_dir(): + return rsa_dir + return rsa_dir + + def get_cert_paths(domain: str) -> dict: """Return the file paths for all certificate components. @@ -399,13 +433,12 @@ def get_cert_paths(domain: str) -> dict: Returns: Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths. """ - acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) - acme_home = str(Path(acme_home_env) / domain) + cert_dir = str(find_cert_dir(domain)) return { - "cert": f"{acme_home}/{domain}.cert", - "key": f"{acme_home}/{domain}.key", - "ca": f"{acme_home}/ca.cer", - "fullchain": f"{acme_home}/fullchain.cer", + "cert": f"{cert_dir}/{domain}.cert", + "key": f"{cert_dir}/{domain}.key", + "ca": f"{cert_dir}/ca.cer", + "fullchain": f"{cert_dir}/fullchain.cer", } @@ -497,9 +530,15 @@ def _days_until(date_str: str) -> int | None: """Parse an ISO date string and return days until that date from now.""" if not date_str: return None - for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"): + for fmt in ( + "%Y-%m-%d", + "%Y-%m-%dT%H:%M:%SZ", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M:%S%z", + ): try: - dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC) + dt = datetime.strptime(date_str, fmt) + dt = dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC) delta = dt - datetime.now(UTC) return delta.days except ValueError: @@ -529,6 +568,7 @@ __all__ = [ "copy_cert", "days_until_expiry", "deploy", + "find_cert_dir", "get_cert_info", "get_cert_paths", "get_email", diff --git a/lib/nginx.py b/lib/nginx.py index 0ff90f8..4ea94ef 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -13,6 +13,7 @@ from typing import Any from jinja2 import Environment, FileSystemLoader +from lib.acme import find_cert_dir from lib.common import ensure_dirs, load_json, save_json logger = logging.getLogger(__name__) @@ -206,6 +207,8 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str: The complete nginx server-block configuration as a string. """ tmpl = ENV.get_template("nginx/server_block.conf") + acme_home_path = PROJECT_DIR / "data" / "acme" + acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path)) return tmpl.render( domain=domain_cfg["domain"], backend=domain_cfg.get("backend", {}), @@ -214,7 +217,7 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str: cert=domain_cfg.get("cert"), auth=domain_cfg.get("auth"), is_management=False, - acme_home=str(PROJECT_DIR / "data" / "acme"), + acme_cert_dir=acme_cert_dir, certs_dir=str(PROJECT_DIR / "data" / "certs"), acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), ) @@ -230,6 +233,8 @@ def _generate_management_conf(management: dict[str, Any]) -> str: The complete nginx server-block configuration for the management UI. """ tmpl = ENV.get_template("nginx/server_block.conf") + acme_home_path = PROJECT_DIR / "data" / "acme" + acme_cert_dir = str(find_cert_dir(management.get("domain", ""), acme_home_path)) return tmpl.render( domain=management.get("domain"), backend=dict( @@ -240,7 +245,7 @@ def _generate_management_conf(management: dict[str, Any]) -> str: cert=None, auth=management.get("auth"), is_management=True, - acme_home=str(PROJECT_DIR / "data" / "acme"), + acme_cert_dir=acme_cert_dir, certs_dir=str(PROJECT_DIR / "data" / "certs"), acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), ) diff --git a/lib/state.py b/lib/state.py index 31df1a7..8d46b66 100644 --- a/lib/state.py +++ b/lib/state.py @@ -702,7 +702,12 @@ def _resolve_ca_name(ca_server: str) -> str: def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]: - """Parse acme.sh .account.conf and return account status dict. + """Parse acme.sh account information and return account status dict. + + Checks three sources in order: + 1. Legacy ``.account.conf`` file (acme.sh v2.x format) + 2. Declarative ``config/acme/config.json`` (saved by the registration + handler with ``email`` and ``ca`` fields) Args: acme_home: Optional override for ACME home directory. Falls back @@ -710,13 +715,12 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]: Returns: Dict with ``registered``, ``email``, ``ca``, and - ``key_length`` keys. If the file is missing or keys are absent, - ``registered`` is ``False`` with empty / ``None`` values. + ``key_length`` keys. If no account is found, ``registered`` is + ``False`` with empty / ``None`` values. """ if acme_home is None: acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme")) acme_home = Path(acme_home_env) - account_path = acme_home / ".account.conf" default = { "registered": False, @@ -725,38 +729,56 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]: "key_length": None, } - if not account_path.is_file(): - return default + # 1. Legacy .account.conf (acme.sh v2.x) + account_path = acme_home / ".account.conf" + if account_path.is_file(): + try: + text = account_path.read_text() + except OSError: + pass + else: + email = "" + ca_raw = "" + key_length = None + for line in text.splitlines(): + if line.startswith("ACME_LEEMAIL="): + email = line.split("=", 1)[1].strip().strip("'\"") + elif line.startswith("ACME_MCA="): + ca_raw = line.split("=", 1)[1].strip().strip("'\"") + elif line.startswith("ACME_CERTKEYSIZE="): + raw_val = line.split("=", 1)[1].strip().strip("'\"") + key_length = int(raw_val) if raw_val.isdigit() else None + if email and ca_raw: + return { + "registered": True, + "email": email, + "ca": _resolve_ca_name(ca_raw), + "key_length": key_length, + } + # 2. Declarative config (saved by register_account / set_email handlers) + # Modern acme.sh (v3.x) stores account data in per-CA JSON files + # (ca//account.json) — we can't reliably parse those without + # walking the directory, so fall back to the declarative config + # which the handlers keep in sync. + # Derive project root from acme_home (acme_home is at /data/acme). try: - text = account_path.read_text() - except OSError: - return default + project_root = acme_home.parent.parent # data/acme → data → project root + acme_cfg = project_root / "config" / "acme" / "config.json" + data = load_json(acme_cfg) + email = (data.get("email") or "").strip() + ca_raw = (data.get("ca") or "").strip() + if email and ca_raw: + return { + "registered": True, + "email": email, + "ca": _resolve_ca_name(ca_raw), + "key_length": None, + } + except (OSError, ValueError): + pass - email = "" - ca_raw = "" - key_length = None - - for line in text.splitlines(): - if line.startswith("ACME_LEEMAIL="): - email = line.split("=", 1)[1].strip().strip("'\"") - elif line.startswith("ACME_MCA="): - ca_raw = line.split("=", 1)[1].strip().strip("'\"") - elif line.startswith("ACME_CERTKEYSIZE="): - raw_val = line.split("=", 1)[1].strip().strip("'\"") - key_length = int(raw_val) if raw_val.isdigit() else None - - if not email or not ca_raw: - return default - - ca = _resolve_ca_name(ca_raw) - - return { - "registered": True, - "email": email, - "ca": ca, - "key_length": key_length, - } + return default def _get_acme_email() -> str: @@ -797,7 +819,8 @@ def _collect_acme() -> dict[str, Any]: if not main: continue san_domains = [ - d.strip() for d in entry.get("san_domains", "").split(",") if d.strip() + d.strip() for d in entry.get("san_domains", "").split(",") + if d.strip() and d.strip().lower() != "no" ] cert_dir = acme_home / main days = _days_until(entry.get("renew", "")) diff --git a/system/nginx/server_block.conf b/system/nginx/server_block.conf index 06ff1ef..6ec87a0 100644 --- a/system/nginx/server_block.conf +++ b/system/nginx/server_block.conf @@ -26,8 +26,8 @@ server { {% if cert.type == "acme" %} # Certificate managed by acme.sh {% if cert.email %} # ACME contact: {{ cert.email }} -{% endif %} ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer; - ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key; +{% endif %} ssl_certificate {{ acme_cert_dir }}/fullchain.cer; + ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; {% elif cert.type == "file" %} ssl_certificate {{ cert.path }}; @@ -39,8 +39,8 @@ server { {% endif %} {% elif is_management %} - ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer; - ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key; + ssl_certificate {{ acme_cert_dir }}/fullchain.cer; + ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; {% endif %} # Shared SSL settings diff --git a/tests/test_acme.py b/tests/test_acme.py index 07fa812..81c523a 100644 --- a/tests/test_acme.py +++ b/tests/test_acme.py @@ -157,6 +157,39 @@ class TestGetEmail: assert result == "test@example.com" +class TestFindCertDir: + def test_rsa_dir(self, tmp_path): + rsa_dir = tmp_path / "example.com" + rsa_dir.mkdir() + result = acme.find_cert_dir("example.com", tmp_path) + assert result == rsa_dir + + def test_ecc_dir(self, tmp_path): + ecc_dir = tmp_path / "example.com_ecc" + ecc_dir.mkdir() + result = acme.find_cert_dir("example.com", tmp_path) + assert result == ecc_dir + + def test_eccPreferred(self, tmp_path): + rsa_dir = tmp_path / "example.com" + rsa_dir.mkdir() + ecc_dir = tmp_path / "example.com_ecc" + ecc_dir.mkdir() + result = acme.find_cert_dir("example.com", tmp_path) + assert result == ecc_dir + + def test_fallback_when_neither(self, tmp_path): + result = acme.find_cert_dir("example.com", tmp_path) + assert result == tmp_path / "example.com" + + def test_resolves_ecc_only(self, tmp_path): + """Only _ecc dir exists, no RSA dir — should resolve to _ecc.""" + ecc_dir = tmp_path / "example.com_ecc" + ecc_dir.mkdir() + result = acme.find_cert_dir("example.com", tmp_path) + assert result == ecc_dir + + class TestGetCertPaths: def test_returns_paths(self, tmp_path): with patch.object(acme, "_ACME_HOME", tmp_path / "data" / "acme"): @@ -166,6 +199,18 @@ class TestGetCertPaths: assert paths["ca"].endswith("example.com/ca.cer") assert paths["fullchain"].endswith("example.com/fullchain.cer") + def test_resolves_ecc_dir(self, tmp_path): + acme_dir = tmp_path / "data" / "acme" + ecc_dir = acme_dir / "example.com_ecc" + ecc_dir.mkdir(parents=True) + + with patch.object(acme, "_ACME_HOME", acme_dir): + paths = acme.get_cert_paths("example.com") + assert paths["cert"].endswith("example.com_ecc/example.com.cert") + assert paths["key"].endswith("example.com_ecc/example.com.key") + assert paths["ca"].endswith("example.com_ecc/ca.cer") + assert paths["fullchain"].endswith("example.com_ecc/fullchain.cer") + class TestDeployHook: @patch("lib.acme._run_acme") diff --git a/tests/test_handler_acme.py b/tests/test_handler_acme.py index 5aa8731..4de5580 100644 --- a/tests/test_handler_acme.py +++ b/tests/test_handler_acme.py @@ -348,10 +348,10 @@ class TestCheckDnsPublic: from unittest.mock import patch mock_result = MagicMock( - returncode=0, stdout="example.com has address 192.168.1.1" + returncode=0, stdout="example.com has address 52.14.150.110" ) with ( - patch("socket.gethostbyname", return_value="192.168.1.1"), + patch("daemon.handlers.acme._get_local_ips", return_value={"52.14.150.110"}), patch("subprocess.run", return_value=mock_result), ): passed, _ = _check_dns_public("example.com") @@ -362,12 +362,23 @@ class TestCheckDnsPublic: mock_result = MagicMock(returncode=1, stdout="NXDOMAIN") with ( - patch("socket.gethostbyname", return_value="192.168.1.1"), + patch("daemon.handlers.acme._get_local_ips", return_value={"8.8.8.8"}), patch("subprocess.run", return_value=mock_result), ): passed, _ = _check_dns_public("example.com") assert passed is False + def test_nat_detected_skips_check(self): + from unittest.mock import patch + + with patch( + "daemon.handlers.acme._get_local_ips", + return_value={"192.168.1.1"}, + ): + passed, msg = _check_dns_public("example.com") + assert passed is True + assert "NAT" in msg + class TestCheckDomainFormat: def test_valid(self): diff --git a/webui/static/app.js b/webui/static/app.js index fa4b8e2..eec5e4c 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -1,16 +1,16 @@ -import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7'; +import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8'; -import DashboardPage from '/static/pages/dashboard.js?v=7'; -import InterfacesPage from '/static/pages/interfaces.js?v=7'; -import ZonesPage from '/static/pages/zones.js?v=7'; -import RulesPage from '/static/pages/rules.js?v=7'; -import NatPage from '/static/pages/nat.js?v=7'; -import DhcpPage from '/static/pages/dhcp.js?v=7'; -import ProxyPage from '/static/pages/proxy.js?v=7'; -import CertsPage from '/static/pages/certs.js?v=7'; -import WireguardPage from '/static/pages/wireguard.js?v=7'; -import LogsPage from '/static/pages/logs.js?v=7'; -import NotFoundPage from '/static/pages/notfound.js?v=7'; +import DashboardPage from '/static/pages/dashboard.js?v=8'; +import InterfacesPage from '/static/pages/interfaces.js?v=8'; +import ZonesPage from '/static/pages/zones.js?v=8'; +import RulesPage from '/static/pages/rules.js?v=8'; +import NatPage from '/static/pages/nat.js?v=8'; +import DhcpPage from '/static/pages/dhcp.js?v=8'; +import ProxyPage from '/static/pages/proxy.js?v=8'; +import CertsPage from '/static/pages/certs.js?v=8'; +import WireguardPage from '/static/pages/wireguard.js?v=8'; +import LogsPage from '/static/pages/logs.js?v=8'; +import NotFoundPage from '/static/pages/notfound.js?v=8'; /* ── Navigation items ──────────────────────────────────────── */ const Nav = [ @@ -229,4 +229,8 @@ export function initApp() { setTimeout(connect, 0); } -document.addEventListener('DOMContentLoaded', initApp); +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initApp); +} else { + initApp(); +} diff --git a/webui/static/index.html b/webui/static/index.html index b360e5e..da4d38b 100644 --- a/webui/static/index.html +++ b/webui/static/index.html @@ -4,7 +4,7 @@ Vacuum Wall - +
@@ -15,6 +15,6 @@
- + diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js index 49b962c..ecaf2ad 100644 --- a/webui/static/pages/certs.js +++ b/webui/static/pages/certs.js @@ -172,7 +172,7 @@ function _renderIssueContent() { -
${...resultsVNodes}
+
${resultsVNodes}