fix: ECC cert support, ACME deploy hook path, NAT detection, and account config fallback
- Add find_cert_dir() to resolve both RSA and ECC (domain_ecc/) cert dirs - Copy acme deploy hook to /deploy/ where acme.sh resolves it - _parse_account_conf checks both legacy .account.conf and declarative config - Skip public DNS check when all local IPs are private (NAT) - Improve check message strings for validity and expiry status - Support timezone-aware date formats in _days_until parsing - Filter out "no" SAN domains in cert listing - Bump frontend asset version cache keys - Fix DOMContentLoaded race condition in app.js boot - Fix spread operator in certs.js modal template
This commit is contained in:
+38
-30
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user