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:
+52
-12
@@ -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 <root>/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",
|
||||
|
||||
+7
-2
@@ -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"),
|
||||
)
|
||||
|
||||
+57
-34
@@ -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/<server>/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 <root>/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", ""))
|
||||
|
||||
Reference in New Issue
Block a user