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:
+37
-29
@@ -40,7 +40,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
_ACME_HOME = PROJECT_DIR / "data" / "acme"
|
_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 = {
|
_ACME_ENVIRON = {
|
||||||
"HOME": str(PROJECT_DIR),
|
"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])?)*$"
|
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):
|
if not _re.match(pattern, domain):
|
||||||
return False, "Invalid domain name format"
|
return False, "Invalid domain name format"
|
||||||
return True, ""
|
return True, "Domain format is valid"
|
||||||
|
|
||||||
|
|
||||||
def _get_local_ips() -> set[str]:
|
def _get_local_ips() -> set[str]:
|
||||||
@@ -343,10 +345,12 @@ def _check_existing_cert(domain: str) -> tuple[bool, str]:
|
|||||||
try:
|
try:
|
||||||
days = lib.acme.days_until_expiry(domain)
|
days = lib.acme.days_until_expiry(domain)
|
||||||
except (RuntimeError, FileNotFoundError):
|
except (RuntimeError, FileNotFoundError):
|
||||||
return True, ""
|
return True, "No existing certificate found"
|
||||||
if days is not None and days > 0:
|
if days is None:
|
||||||
|
return True, "No existing certificate found"
|
||||||
|
if days > 0:
|
||||||
return True, f"Valid certificate exists ({days} days remaining)"
|
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]:
|
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]:
|
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:
|
try:
|
||||||
acme_bin = _find_acme_bin()
|
acme_bin = _find_acme_bin()
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
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):
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
from lib.state import _parse_account_conf
|
||||||
account_conf = _ACME_HOME / ".account.conf"
|
|
||||||
if account_conf.is_file():
|
info = _parse_account_conf(_ACME_HOME)
|
||||||
text = account_conf.read_text()
|
if info.get("registered"):
|
||||||
if "ACME_LEEMAIL" in text and "ACME_MCA" in text:
|
|
||||||
return True, "ACME account is configured"
|
return True, "ACME account is configured"
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
False,
|
False,
|
||||||
@@ -538,17 +543,14 @@ def _check_acme_account() -> tuple[bool, str]:
|
|||||||
def _check_account_registered() -> tuple[bool, str]:
|
def _check_account_registered() -> tuple[bool, str]:
|
||||||
"""Blocking check: verify an ACME account is registered.
|
"""Blocking check: verify an ACME account is registered.
|
||||||
|
|
||||||
Reads the user-facing .account.conf (with leading dot) which stores
|
Delegates to ``lib.state._parse_account_conf()`` which checks both
|
||||||
the registered account's ACME_LEEMAIL and ACME_MCA keys.
|
the legacy .account.conf and the declarative config/acme/config.json
|
||||||
|
used by modern acme.sh (v3.x).
|
||||||
"""
|
"""
|
||||||
account_conf = _ACME_HOME / ".account.conf"
|
from lib.state import _parse_account_conf
|
||||||
if not account_conf.is_file():
|
|
||||||
return False, "Register an ACME account before issuing certificates"
|
info = _parse_account_conf(_ACME_HOME)
|
||||||
try:
|
if info.get("registered"):
|
||||||
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:
|
|
||||||
return True, "ACME account is registered"
|
return True, "ACME account is registered"
|
||||||
return False, "Register an ACME account before issuing certificates"
|
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:
|
if not local_ips:
|
||||||
return True, "Public DNS check skipped (no local IPs detected)"
|
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"):
|
for dns_server in ("8.8.8.8", "1.1.1.1"):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
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:
|
if not body or "domain" not in body:
|
||||||
raise ValueError("'domain' is required")
|
raise ValueError("'domain' is required")
|
||||||
domain = body["domain"]
|
domain = body["domain"]
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
from lib.acme import find_cert_dir
|
||||||
acme_home = str(Path(acme_home_env) / domain)
|
|
||||||
|
cert_dir = str(find_cert_dir(domain, _ACME_HOME))
|
||||||
return {
|
return {
|
||||||
"cert": f"{acme_home}/{domain}.cert",
|
"cert": f"{cert_dir}/{domain}.cert",
|
||||||
"key": f"{acme_home}/{domain}.key",
|
"key": f"{cert_dir}/{domain}.key",
|
||||||
"ca": f"{acme_home}/ca.cer",
|
"ca": f"{cert_dir}/ca.cer",
|
||||||
"fullchain": f"{acme_home}/fullchain.cer",
|
"fullchain": f"{cert_dir}/fullchain.cer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from daemon.iface import (
|
|||||||
POST_NGINX_TEST,
|
POST_NGINX_TEST,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
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
|
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -104,6 +105,8 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
|||||||
Rendered server block as a string.
|
Rendered server block as a string.
|
||||||
"""
|
"""
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
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(
|
return tmpl.render(
|
||||||
domain=domain_cfg["domain"],
|
domain=domain_cfg["domain"],
|
||||||
backend=domain_cfg.get("backend", {}),
|
backend=domain_cfg.get("backend", {}),
|
||||||
@@ -112,7 +115,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
|||||||
cert=domain_cfg.get("cert"),
|
cert=domain_cfg.get("cert"),
|
||||||
auth=domain_cfg.get("auth"),
|
auth=domain_cfg.get("auth"),
|
||||||
is_management=False,
|
is_management=False,
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
acme_cert_dir=acme_cert_dir,
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
)
|
)
|
||||||
@@ -209,6 +212,8 @@ def _write_all_sites() -> None:
|
|||||||
if cfg.get("management"):
|
if cfg.get("management"):
|
||||||
mgmt = cfg["management"]
|
mgmt = cfg["management"]
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
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(
|
mgmt_conf = tmpl.render(
|
||||||
domain=mgmt.get("domain"),
|
domain=mgmt.get("domain"),
|
||||||
backend=dict(
|
backend=dict(
|
||||||
@@ -219,7 +224,7 @@ def _write_all_sites() -> None:
|
|||||||
cert=None,
|
cert=None,
|
||||||
auth=mgmt.get("auth"),
|
auth=mgmt.get("auth"),
|
||||||
is_management=True,
|
is_management=True,
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
acme_cert_dir=acme_cert_dir,
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-2
@@ -218,8 +218,12 @@ else
|
|||||||
log "acme.sh already installed."
|
log "acme.sh already installed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure the acme deploy hook script has correct permissions
|
# Install the deploy hook into acme.sh's deploy directory
|
||||||
chmod 0755 "${PROJECT_DIR}/system/acme-deploy.sh"
|
# (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 ---
|
# --- 3. Setup directories ---
|
||||||
log "Creating config and data directories..."
|
log "Creating config and data directories..."
|
||||||
|
|||||||
+52
-12
@@ -18,7 +18,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
_ACME_HOME = PROJECT_DIR / "data" / "acme"
|
_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 = {
|
_ACME_ENVIRON = {
|
||||||
"HOME": str(PROJECT_DIR),
|
"HOME": str(PROJECT_DIR),
|
||||||
@@ -157,10 +159,12 @@ def _read_acme_email() -> str:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning("Could not read account config: %s", exc)
|
logger.warning("Could not read account config: %s", exc)
|
||||||
# Fallback: read from declarative ACME config
|
# Fallback: read from declarative ACME config
|
||||||
|
# Derive project root from acme_home (acme_home is at <root>/data/acme).
|
||||||
try:
|
try:
|
||||||
from lib.common import load_json
|
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)
|
conf = load_json(acme_cfg)
|
||||||
if conf and "email" in conf:
|
if conf and "email" in conf:
|
||||||
return conf["email"]
|
return conf["email"]
|
||||||
@@ -257,10 +261,12 @@ def list_certs() -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
san_domains = [
|
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")
|
cert_path = str(cert_dir / "fullchain.cer")
|
||||||
key_path = str(cert_dir / f"{main}.key")
|
key_path = str(cert_dir / f"{main}.key")
|
||||||
ca_path = str(cert_dir / "ca.cer")
|
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:
|
def get_cert_paths(domain: str) -> dict:
|
||||||
"""Return the file paths for all certificate components.
|
"""Return the file paths for all certificate components.
|
||||||
|
|
||||||
@@ -399,13 +433,12 @@ def get_cert_paths(domain: str) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
|
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
|
||||||
"""
|
"""
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
cert_dir = str(find_cert_dir(domain))
|
||||||
acme_home = str(Path(acme_home_env) / domain)
|
|
||||||
return {
|
return {
|
||||||
"cert": f"{acme_home}/{domain}.cert",
|
"cert": f"{cert_dir}/{domain}.cert",
|
||||||
"key": f"{acme_home}/{domain}.key",
|
"key": f"{cert_dir}/{domain}.key",
|
||||||
"ca": f"{acme_home}/ca.cer",
|
"ca": f"{cert_dir}/ca.cer",
|
||||||
"fullchain": f"{acme_home}/fullchain.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."""
|
"""Parse an ISO date string and return days until that date from now."""
|
||||||
if not date_str:
|
if not date_str:
|
||||||
return None
|
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:
|
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)
|
delta = dt - datetime.now(UTC)
|
||||||
return delta.days
|
return delta.days
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -529,6 +568,7 @@ __all__ = [
|
|||||||
"copy_cert",
|
"copy_cert",
|
||||||
"days_until_expiry",
|
"days_until_expiry",
|
||||||
"deploy",
|
"deploy",
|
||||||
|
"find_cert_dir",
|
||||||
"get_cert_info",
|
"get_cert_info",
|
||||||
"get_cert_paths",
|
"get_cert_paths",
|
||||||
"get_email",
|
"get_email",
|
||||||
|
|||||||
+7
-2
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from lib.acme import find_cert_dir
|
||||||
from lib.common import ensure_dirs, load_json, save_json
|
from lib.common import ensure_dirs, load_json, save_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
The complete nginx server-block configuration as a string.
|
||||||
"""
|
"""
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
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(
|
return tmpl.render(
|
||||||
domain=domain_cfg["domain"],
|
domain=domain_cfg["domain"],
|
||||||
backend=domain_cfg.get("backend", {}),
|
backend=domain_cfg.get("backend", {}),
|
||||||
@@ -214,7 +217,7 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
|||||||
cert=domain_cfg.get("cert"),
|
cert=domain_cfg.get("cert"),
|
||||||
auth=domain_cfg.get("auth"),
|
auth=domain_cfg.get("auth"),
|
||||||
is_management=False,
|
is_management=False,
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
acme_cert_dir=acme_cert_dir,
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
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.
|
The complete nginx server-block configuration for the management UI.
|
||||||
"""
|
"""
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
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(
|
return tmpl.render(
|
||||||
domain=management.get("domain"),
|
domain=management.get("domain"),
|
||||||
backend=dict(
|
backend=dict(
|
||||||
@@ -240,7 +245,7 @@ def _generate_management_conf(management: dict[str, Any]) -> str:
|
|||||||
cert=None,
|
cert=None,
|
||||||
auth=management.get("auth"),
|
auth=management.get("auth"),
|
||||||
is_management=True,
|
is_management=True,
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
acme_cert_dir=acme_cert_dir,
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
)
|
)
|
||||||
|
|||||||
+41
-18
@@ -702,7 +702,12 @@ def _resolve_ca_name(ca_server: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
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:
|
Args:
|
||||||
acme_home: Optional override for ACME home directory. Falls back
|
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:
|
Returns:
|
||||||
Dict with ``registered``, ``email``, ``ca``, and
|
Dict with ``registered``, ``email``, ``ca``, and
|
||||||
``key_length`` keys. If the file is missing or keys are absent,
|
``key_length`` keys. If no account is found, ``registered`` is
|
||||||
``registered`` is ``False`` with empty / ``None`` values.
|
``False`` with empty / ``None`` values.
|
||||||
"""
|
"""
|
||||||
if acme_home is None:
|
if acme_home is None:
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||||
acme_home = Path(acme_home_env)
|
acme_home = Path(acme_home_env)
|
||||||
account_path = acme_home / ".account.conf"
|
|
||||||
|
|
||||||
default = {
|
default = {
|
||||||
"registered": False,
|
"registered": False,
|
||||||
@@ -725,18 +729,17 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
|||||||
"key_length": None,
|
"key_length": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not account_path.is_file():
|
# 1. Legacy .account.conf (acme.sh v2.x)
|
||||||
return default
|
account_path = acme_home / ".account.conf"
|
||||||
|
if account_path.is_file():
|
||||||
try:
|
try:
|
||||||
text = account_path.read_text()
|
text = account_path.read_text()
|
||||||
except OSError:
|
except OSError:
|
||||||
return default
|
pass
|
||||||
|
else:
|
||||||
email = ""
|
email = ""
|
||||||
ca_raw = ""
|
ca_raw = ""
|
||||||
key_length = None
|
key_length = None
|
||||||
|
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
if line.startswith("ACME_LEEMAIL="):
|
if line.startswith("ACME_LEEMAIL="):
|
||||||
email = line.split("=", 1)[1].strip().strip("'\"")
|
email = line.split("=", 1)[1].strip().strip("'\"")
|
||||||
@@ -745,19 +748,38 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
|||||||
elif line.startswith("ACME_CERTKEYSIZE="):
|
elif line.startswith("ACME_CERTKEYSIZE="):
|
||||||
raw_val = line.split("=", 1)[1].strip().strip("'\"")
|
raw_val = line.split("=", 1)[1].strip().strip("'\"")
|
||||||
key_length = int(raw_val) if raw_val.isdigit() else None
|
key_length = int(raw_val) if raw_val.isdigit() else None
|
||||||
|
if email and ca_raw:
|
||||||
if not email or not ca_raw:
|
|
||||||
return default
|
|
||||||
|
|
||||||
ca = _resolve_ca_name(ca_raw)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"registered": True,
|
"registered": True,
|
||||||
"email": email,
|
"email": email,
|
||||||
"ca": ca,
|
"ca": _resolve_ca_name(ca_raw),
|
||||||
"key_length": key_length,
|
"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:
|
||||||
|
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
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _get_acme_email() -> str:
|
def _get_acme_email() -> str:
|
||||||
"""Read the ACME ``acme.sh`` email from the account config file.
|
"""Read the ACME ``acme.sh`` email from the account config file.
|
||||||
@@ -797,7 +819,8 @@ def _collect_acme() -> dict[str, Any]:
|
|||||||
if not main:
|
if not main:
|
||||||
continue
|
continue
|
||||||
san_domains = [
|
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 = acme_home / main
|
||||||
days = _days_until(entry.get("renew", ""))
|
days = _days_until(entry.get("renew", ""))
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ server {
|
|||||||
{% if cert.type == "acme" %}
|
{% if cert.type == "acme" %}
|
||||||
# Certificate managed by acme.sh
|
# Certificate managed by acme.sh
|
||||||
{% if cert.email %} # ACME contact: {{ cert.email }}
|
{% if cert.email %} # ACME contact: {{ cert.email }}
|
||||||
{% endif %} ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer;
|
{% endif %} ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
|
||||||
ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key;
|
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
|
||||||
|
|
||||||
{% elif cert.type == "file" %}
|
{% elif cert.type == "file" %}
|
||||||
ssl_certificate {{ cert.path }};
|
ssl_certificate {{ cert.path }};
|
||||||
@@ -39,8 +39,8 @@ server {
|
|||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif is_management %}
|
{% elif is_management %}
|
||||||
ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer;
|
ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
|
||||||
ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key;
|
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
# Shared SSL settings
|
# Shared SSL settings
|
||||||
|
|||||||
@@ -157,6 +157,39 @@ class TestGetEmail:
|
|||||||
assert result == "test@example.com"
|
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:
|
class TestGetCertPaths:
|
||||||
def test_returns_paths(self, tmp_path):
|
def test_returns_paths(self, tmp_path):
|
||||||
with patch.object(acme, "_ACME_HOME", tmp_path / "data" / "acme"):
|
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["ca"].endswith("example.com/ca.cer")
|
||||||
assert paths["fullchain"].endswith("example.com/fullchain.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:
|
class TestDeployHook:
|
||||||
@patch("lib.acme._run_acme")
|
@patch("lib.acme._run_acme")
|
||||||
|
|||||||
@@ -348,10 +348,10 @@ class TestCheckDnsPublic:
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
mock_result = MagicMock(
|
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 (
|
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),
|
patch("subprocess.run", return_value=mock_result),
|
||||||
):
|
):
|
||||||
passed, _ = _check_dns_public("example.com")
|
passed, _ = _check_dns_public("example.com")
|
||||||
@@ -362,12 +362,23 @@ class TestCheckDnsPublic:
|
|||||||
|
|
||||||
mock_result = MagicMock(returncode=1, stdout="NXDOMAIN")
|
mock_result = MagicMock(returncode=1, stdout="NXDOMAIN")
|
||||||
with (
|
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),
|
patch("subprocess.run", return_value=mock_result),
|
||||||
):
|
):
|
||||||
passed, _ = _check_dns_public("example.com")
|
passed, _ = _check_dns_public("example.com")
|
||||||
assert passed is False
|
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:
|
class TestCheckDomainFormat:
|
||||||
def test_valid(self):
|
def test_valid(self):
|
||||||
|
|||||||
+17
-13
@@ -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 DashboardPage from '/static/pages/dashboard.js?v=8';
|
||||||
import InterfacesPage from '/static/pages/interfaces.js?v=7';
|
import InterfacesPage from '/static/pages/interfaces.js?v=8';
|
||||||
import ZonesPage from '/static/pages/zones.js?v=7';
|
import ZonesPage from '/static/pages/zones.js?v=8';
|
||||||
import RulesPage from '/static/pages/rules.js?v=7';
|
import RulesPage from '/static/pages/rules.js?v=8';
|
||||||
import NatPage from '/static/pages/nat.js?v=7';
|
import NatPage from '/static/pages/nat.js?v=8';
|
||||||
import DhcpPage from '/static/pages/dhcp.js?v=7';
|
import DhcpPage from '/static/pages/dhcp.js?v=8';
|
||||||
import ProxyPage from '/static/pages/proxy.js?v=7';
|
import ProxyPage from '/static/pages/proxy.js?v=8';
|
||||||
import CertsPage from '/static/pages/certs.js?v=7';
|
import CertsPage from '/static/pages/certs.js?v=8';
|
||||||
import WireguardPage from '/static/pages/wireguard.js?v=7';
|
import WireguardPage from '/static/pages/wireguard.js?v=8';
|
||||||
import LogsPage from '/static/pages/logs.js?v=7';
|
import LogsPage from '/static/pages/logs.js?v=8';
|
||||||
import NotFoundPage from '/static/pages/notfound.js?v=7';
|
import NotFoundPage from '/static/pages/notfound.js?v=8';
|
||||||
|
|
||||||
/* ── Navigation items ──────────────────────────────────────── */
|
/* ── Navigation items ──────────────────────────────────────── */
|
||||||
const Nav = [
|
const Nav = [
|
||||||
@@ -229,4 +229,8 @@ export function initApp() {
|
|||||||
setTimeout(connect, 0);
|
setTimeout(connect, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initApp);
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initApp);
|
||||||
|
} else {
|
||||||
|
initApp();
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vacuum Wall</title>
|
<title>Vacuum Wall</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=8">
|
<link rel="stylesheet" href="/static/style.css?v=9">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -15,6 +15,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="modal-root"></div>
|
<div id="modal-root"></div>
|
||||||
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
|
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
|
||||||
<script type="module" src="/static/app.js?v=8"></script>
|
<script type="module" src="/static/app.js?v=10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ function _renderIssueContent() {
|
|||||||
<label>Domain</label>
|
<label>Domain</label>
|
||||||
<input id="ic-domain" value=${esc(s.domain)} />
|
<input id="ic-domain" value=${esc(s.domain)} />
|
||||||
</div>
|
</div>
|
||||||
<div id="ic-vresults">${...resultsVNodes}</div>
|
<div id="ic-vresults">${resultsVNodes}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
|
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user