feat: add ACME account management with validation pipeline
- Register, view, and deactivate ACME accounts via API and UI - 16-check validation framework for certificate issuance readiness - DNS resolution, port, nginx, and firewall pre-flight checks - External IP detection with NAT support and fallback providers - Account card and settings modal in certificates page - Guard certificate issuance behind account registration - Update modal CSS to overlay-based approach - 1000+ lines of tests for validation and account handlers
This commit is contained in:
+449
-45
@@ -1,10 +1,13 @@
|
||||
"""ACME certificate daemon handler."""
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
@@ -12,13 +15,17 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import lib.common as lib_common
|
||||
from daemon.iface import (
|
||||
DELETE_ACME_ACCOUNT_DEACTIVATE,
|
||||
DELETE_ACME_REMOVE,
|
||||
GET_ACME_ACCOUNT,
|
||||
GET_ACME_EMAIL,
|
||||
GET_ACME_INFO,
|
||||
GET_ACME_ISSUE_STATUS,
|
||||
GET_ACME_LIST,
|
||||
GET_ACME_PATHS,
|
||||
POST_ACME_ACCOUNT_REGISTER,
|
||||
POST_ACME_EMAIL,
|
||||
POST_ACME_ISSUE,
|
||||
POST_ACME_RENEW,
|
||||
@@ -168,54 +175,119 @@ def _check_domain_format(domain: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def _get_local_ips() -> set[str]:
|
||||
"""Return the set of all non-loopback IPv4 addresses on this host."""
|
||||
import struct
|
||||
from fcntl import ioctl
|
||||
|
||||
ips: set[str] = set()
|
||||
with suppress(OSError):
|
||||
ips.add(socket.gethostbyname(socket.gethostname()))
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
names = b"\x00" * 4096
|
||||
raw = ioctl(s.fileno(), 0x8912, names)
|
||||
s.close()
|
||||
for i in range(0, 4096, 32):
|
||||
name = raw[i : i + 16].split(b"\x00")[0].decode()
|
||||
if name == "lo":
|
||||
continue
|
||||
addr = struct.unpack("<I", raw[i + 16 : i + 20])[0]
|
||||
ips.add(str(ipaddress.IPv4Address(addr)))
|
||||
except Exception:
|
||||
pass
|
||||
return ips
|
||||
|
||||
|
||||
def _get_external_ip(timeout: int = 5) -> str | None:
|
||||
"""Fetch the server's public IP address from external services.
|
||||
|
||||
Returns None on error or timeout. Honours VACUUM_WALL_EXTERNAL_IP_URL
|
||||
env var for testing or custom providers.
|
||||
"""
|
||||
urls: list[str] = []
|
||||
|
||||
custom_url = os.environ.get("VACUUM_WALL_EXTERNAL_IP_URL")
|
||||
if custom_url:
|
||||
urls.append(custom_url)
|
||||
else:
|
||||
urls.append("https://api.ipify.org")
|
||||
urls.append("https://checkip.amazonaws.com")
|
||||
|
||||
for url in urls:
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "vacuum-wall/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read().decode().strip()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_private_ip(ip_str: str) -> bool:
|
||||
"""Return True if the IP address is not globally routable."""
|
||||
try:
|
||||
return not ipaddress.ip_address(ip_str).is_global
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _check_dns_resolves(domain: str) -> tuple[bool, str]:
|
||||
"""Check that domain resolves to this machine's IP via A record."""
|
||||
"""Check that domain resolves to this machine's IP (NAT-aware).
|
||||
|
||||
1. Match against local interface IPs — pass immediately.
|
||||
2. If no local match, compare against external IP for NAT scenarios.
|
||||
3. If ext IP lookup fails, downgrade to non-blocking warning.
|
||||
4. Private-range resolved IP always fails.
|
||||
"""
|
||||
try:
|
||||
results = socket.getaddrinfo(domain, 80, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
if not results:
|
||||
return False, "Domain does not resolve to any address"
|
||||
|
||||
local_ips = set()
|
||||
hostname = socket.gethostname()
|
||||
with suppress(OSError):
|
||||
local_ips.add(socket.gethostbyname(hostname))
|
||||
# Also collect all interface IPs
|
||||
try:
|
||||
import ipaddress
|
||||
from fcntl import ioctl
|
||||
resolved_ips = [addr[4][0] for addr in results]
|
||||
local_ips = _get_local_ips()
|
||||
|
||||
def get_interfaces():
|
||||
import struct
|
||||
# Step 1: direct local match
|
||||
for rip in resolved_ips:
|
||||
if rip in local_ips:
|
||||
return True, "DNS resolves correctly"
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
names = b"\x00" * 4096
|
||||
raw = ioctl(s.fileno(), 0x8912, names)
|
||||
s.close()
|
||||
ifaces = []
|
||||
for i in range(0, 4096, 32):
|
||||
name = raw[i : i + 16].split(b"\x00")[0].decode()
|
||||
if name == "lo":
|
||||
continue
|
||||
addr = struct.unpack("<I", raw[i + 16 : i + 20])[0]
|
||||
ifaces.append(str(ipaddress.IPv4Address(addr)))
|
||||
return ifaces
|
||||
# Step 2: NAT — compare against external IP
|
||||
external_ip = _get_external_ip()
|
||||
if external_ip:
|
||||
for rip in resolved_ips:
|
||||
if rip == external_ip:
|
||||
return (
|
||||
True,
|
||||
"DNS resolves correctly (matches external IP — server is behind NAT)",
|
||||
)
|
||||
|
||||
local_ips.update(get_interfaces())
|
||||
except Exception:
|
||||
pass
|
||||
# Resolved IP is public but doesn't match external IP
|
||||
for rip in resolved_ips:
|
||||
if not _is_private_ip(rip):
|
||||
return (
|
||||
False,
|
||||
f"Domain resolves to {rip} but external IP is {external_ip}. "
|
||||
f"Check your DNS A record points to this server's public IP.",
|
||||
)
|
||||
|
||||
resolved = False
|
||||
for _, _, _, _, addr in results:
|
||||
if addr in local_ips:
|
||||
resolved = True
|
||||
break
|
||||
# Step 3: external IP unavailable — check for private range first, then warn
|
||||
for rip in resolved_ips:
|
||||
if _is_private_ip(rip):
|
||||
return (
|
||||
False,
|
||||
f"Domain resolves to private IP {rip}. "
|
||||
f"Ensure public DNS points to this server's public IP.",
|
||||
)
|
||||
|
||||
if resolved:
|
||||
return True, "DNS resolves correctly"
|
||||
return (
|
||||
False,
|
||||
f"Domain resolves to {results[0][4][0]}, not this server",
|
||||
f"Domain resolves to {resolved_ips[0]}, not a local interface IP. "
|
||||
f"Cannot verify via external IP (lookup failed).",
|
||||
)
|
||||
|
||||
except socket.gaierror:
|
||||
return False, "Domain does not resolve (NXDOMAIN or timeout)"
|
||||
|
||||
@@ -234,7 +306,10 @@ def _check_email_configured() -> tuple[bool, str]:
|
||||
email = _get_acme_email() or ""
|
||||
if email:
|
||||
return True, f"Contact email configured: {email}"
|
||||
return False, "No ACME contact email configured"
|
||||
return (
|
||||
False,
|
||||
"Contact email not set — configure in Account Settings for renewal notifications",
|
||||
)
|
||||
|
||||
|
||||
def _check_webroot() -> tuple[bool, str]:
|
||||
@@ -267,18 +342,275 @@ def _check_existing_cert(domain: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def _check_nginx_running() -> tuple[bool, str]:
|
||||
"""Check whether the nginx process is currently running."""
|
||||
try:
|
||||
result = lib_common.run_proc(
|
||||
["systemctl", "is-active", "nginx"], sudo=True, check=False, timeout=10
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
return True, "nginx is running"
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
try:
|
||||
pid_file = Path("/var/run/nginx.pid")
|
||||
if pid_file.is_file():
|
||||
pid = int(pid_file.read_text().strip())
|
||||
proc_status = Path(f"/proc/{pid}/status")
|
||||
if proc_status.is_file():
|
||||
return True, "nginx is running"
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
return False, "nginx is not running — start it before issuing certificates"
|
||||
|
||||
|
||||
def _check_nginx_config() -> tuple[bool, str]:
|
||||
"""Test nginx configuration syntax via ``nginx -t``."""
|
||||
from lib.nginx import test_config
|
||||
|
||||
ok, msg = test_config()
|
||||
if ok:
|
||||
return True, "nginx configuration is valid"
|
||||
return False, f"nginx configuration test failed: {msg}"
|
||||
|
||||
|
||||
def _check_firewall_port_80() -> tuple[bool, str]:
|
||||
"""Check that port 80/tcp is open in firewalld across all active zones."""
|
||||
try:
|
||||
proc = lib_common.run_proc(
|
||||
["firewall-cmd", "--get-active-zones"], sudo=True, check=False, timeout=10
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return True, "firewalld not detected, skipping port check"
|
||||
|
||||
zone_lines = proc.stdout.strip()
|
||||
if not zone_lines:
|
||||
return True, "firewalld not detected, skipping port check"
|
||||
|
||||
zones = _parse_active_zones(zone_lines)
|
||||
port_open = False
|
||||
|
||||
for zone in zones:
|
||||
proc = lib_common.run_proc(
|
||||
["firewall-cmd", f"--zone={zone}", "--list-services"],
|
||||
sudo=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
if "http" in (proc.stdout or "").split():
|
||||
port_open = True
|
||||
break
|
||||
|
||||
proc = lib_common.run_proc(
|
||||
["firewall-cmd", f"--zone={zone}", "--list-ports"],
|
||||
sudo=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
for item in (proc.stdout or "").split():
|
||||
if "80" in item.split("/"):
|
||||
port_open = True
|
||||
break
|
||||
|
||||
if port_open:
|
||||
break
|
||||
|
||||
if port_open:
|
||||
return True, "Port 80 is open in firewall"
|
||||
return (
|
||||
False,
|
||||
"Port 80 blocked by firewall — allow with: firewall-cmd --add-service=http --permanent && firewall-cmd --reload",
|
||||
)
|
||||
except Exception:
|
||||
return True, "firewalld check unavailable, skipping"
|
||||
|
||||
|
||||
def _parse_active_zones(output: str) -> list[str]:
|
||||
"""Parse ``firewall-cmd --get-active-zones`` output into zone names."""
|
||||
zones = []
|
||||
for line in output.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith(" "):
|
||||
zones.append(stripped.removesuffix(" (default)"))
|
||||
return zones
|
||||
|
||||
|
||||
def _check_acme_home_writable() -> tuple[bool, str]:
|
||||
"""Verify data/acme/ is writable with a temporary file probe."""
|
||||
if not _ACME_HOME.is_dir():
|
||||
return False, "ACME home directory does not exist"
|
||||
if not os.access(str(_ACME_HOME), os.W_OK):
|
||||
return False, "ACME home directory is not writable"
|
||||
try:
|
||||
probe = _ACME_HOME / ".write-probe"
|
||||
probe.write_text("ok")
|
||||
probe.unlink()
|
||||
return True, "ACME home directory is writable"
|
||||
except OSError:
|
||||
return False, "ACME home directory is not writable"
|
||||
|
||||
|
||||
def _check_openssl_available() -> tuple[bool, str]:
|
||||
"""Verify openssl binary is available and functional."""
|
||||
openssl_path = shutil.which("openssl")
|
||||
if not openssl_path:
|
||||
return False, "openssl binary not found"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["openssl", "version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
ver = result.stdout.strip()
|
||||
return True, f"openssl available ({ver})"
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
return False, "openssl is not working"
|
||||
|
||||
|
||||
def _check_port_80_listening() -> tuple[bool, str]:
|
||||
"""Check that something is listening on port 80 (IPv4 or IPv6)."""
|
||||
# Check localhost first
|
||||
for af, host in [(socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")]:
|
||||
with suppress(OSError), socket.socket(af, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(2)
|
||||
if s.connect_ex((host, 80)) == 0:
|
||||
return True, "Port 80 is listening"
|
||||
# Also check all interface IPs in case nginx only binds on a public interface
|
||||
for ip in _get_local_ips():
|
||||
with suppress(OSError), socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(2)
|
||||
if s.connect_ex((ip, 80)) == 0:
|
||||
return True, "Port 80 is listening"
|
||||
return False, "Nothing listening on port 80 — needed for ACME HTTP-01 challenge"
|
||||
|
||||
|
||||
def _check_acme_account() -> tuple[bool, str]:
|
||||
"""Non-blocking: check acme.sh account is configured."""
|
||||
try:
|
||||
acme_bin = _find_acme_bin()
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||
result = subprocess.run(
|
||||
[
|
||||
acme_bin,
|
||||
"--home",
|
||||
acme_home_env,
|
||||
"--config-home",
|
||||
acme_home_env,
|
||||
"--info",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env={**os.environ, **_ACME_ENVIRON},
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True, "ACME account is configured"
|
||||
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
|
||||
|
||||
return (
|
||||
False,
|
||||
"ACME account may need re-registration — check email is set before issuance",
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
return True, "ACME account is registered"
|
||||
return False, "Register an ACME account before issuing certificates"
|
||||
|
||||
|
||||
def _get_account_info() -> dict[str, Any]:
|
||||
"""Read and return the ACME account info dict.
|
||||
|
||||
Delegates to ``lib.state._parse_account_conf()`` for a single
|
||||
source of truth.
|
||||
"""
|
||||
from lib.state import _parse_account_conf
|
||||
|
||||
return _parse_account_conf(_ACME_HOME)
|
||||
|
||||
|
||||
def _check_dns_public(domain: str) -> tuple[bool, str]:
|
||||
"""Non-blocking: verify public DNS resolves domain to this server."""
|
||||
local_ips = _get_local_ips()
|
||||
|
||||
if not local_ips:
|
||||
return True, "Public DNS check skipped (no local IPs detected)"
|
||||
|
||||
for dns_server in ("8.8.8.8", "1.1.1.1"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["host", domain, dns_server],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
output = result.stdout or ""
|
||||
for ip in local_ips:
|
||||
for line in output.splitlines():
|
||||
if ip in line.split():
|
||||
return True, "Public DNS resolves correctly"
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
|
||||
return (
|
||||
False,
|
||||
"Public DNS may not resolve to this server — allow a few minutes for propagation",
|
||||
)
|
||||
|
||||
|
||||
def _validate(domain: str) -> dict[str, Any]:
|
||||
"""Run all pre-checks for a domain. Returns structured results."""
|
||||
checks: list[dict[str, Any]] = []
|
||||
ready = True
|
||||
|
||||
check_fns = [
|
||||
# Environment — must be present before anything else
|
||||
("acme_installed", _check_acme_installed, True),
|
||||
("email_configured", _check_email_configured, True),
|
||||
("openssl_available", _check_openssl_available, True),
|
||||
("acme_home_writable", _check_acme_home_writable, True),
|
||||
("account_registered", _check_account_registered, True),
|
||||
("email_configured", _check_email_configured, False),
|
||||
("acme_account_valid", _check_acme_account, False),
|
||||
("webroot_ready", _check_webroot, True),
|
||||
# Nginx stack — must serve challenges
|
||||
("nginx_running", _check_nginx_running, True),
|
||||
("nginx_config_valid", _check_nginx_config, True),
|
||||
("challenge_configured", _check_challenge_config, True),
|
||||
("port_80_listening", _check_port_80_listening, True),
|
||||
("firewall_open", _check_firewall_port_80, True),
|
||||
# Domain — must be reachable
|
||||
("domain_format", lambda: _check_domain_format(domain), True),
|
||||
("dns_resolves", lambda: _check_dns_resolves(domain), True),
|
||||
("dns_public", lambda: _check_dns_public(domain), False),
|
||||
# Existing cert — informational
|
||||
("existing_cert", lambda: _check_existing_cert(domain), False),
|
||||
]
|
||||
|
||||
@@ -299,7 +631,8 @@ def _validate(domain: str) -> dict[str, Any]:
|
||||
"blocking": blocking,
|
||||
}
|
||||
)
|
||||
ready = False
|
||||
if blocking:
|
||||
ready = False
|
||||
|
||||
return {"domain": domain, "checks": checks, "ready": ready}
|
||||
|
||||
@@ -360,10 +693,16 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
domain = (body.get("domain") or "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
email = body.get("email", "").strip() or None
|
||||
# email is kept for backward API compatibility but ignored —
|
||||
# _run_issue() uses the registered account's email instead
|
||||
provided_email = (body.get("email") or "").strip()
|
||||
if provided_email:
|
||||
logger.warning(
|
||||
"email field in issue/start is ignored, using registered account's email"
|
||||
)
|
||||
webroot = body.get("webroot")
|
||||
|
||||
_clean_expired_issuances()
|
||||
@@ -399,7 +738,6 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
req = IssueRequest(
|
||||
request_id=request_id,
|
||||
domain=domain,
|
||||
email=email,
|
||||
webroot=webroot,
|
||||
steps=steps,
|
||||
)
|
||||
@@ -437,11 +775,9 @@ async def _run_issue(req: IssueRequest) -> None:
|
||||
req.steps[0].status = "running"
|
||||
args: list[str] = ["--issue", "-d", req.domain]
|
||||
args.extend(["--webroot", req.webroot or str(_WEBROOT)])
|
||||
contact = req.email
|
||||
if not contact:
|
||||
contact = _get_acme_email()
|
||||
if contact:
|
||||
args.extend(["-m", contact])
|
||||
account_email = _get_acme_email()
|
||||
if account_email:
|
||||
args.extend(["-m", account_email])
|
||||
args.append("--force")
|
||||
output = _run_acme(args)
|
||||
req.steps[0].status = "done"
|
||||
@@ -647,3 +983,71 @@ def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str
|
||||
"key": str(key_file),
|
||||
"generated": True,
|
||||
}
|
||||
|
||||
|
||||
@registry.register(GET_ACME_ACCOUNT)
|
||||
def get_account(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /acme/account — return ACME account information."""
|
||||
return _get_account_info()
|
||||
|
||||
|
||||
@registry.register(POST_ACME_ACCOUNT_REGISTER)
|
||||
def register_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/account/register — register a new ACME account.
|
||||
|
||||
Raises:
|
||||
ValueError: When email is missing or invalid.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
email = (body.get("email") or "").strip()
|
||||
if not email:
|
||||
raise ValueError("'email' is required")
|
||||
import re as _re
|
||||
|
||||
if not _re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
|
||||
raise ValueError("Invalid email format")
|
||||
server = (body.get("server") or "letsencrypt").strip()
|
||||
|
||||
_run_acme(["--register-account", "-m", email, "--server", server])
|
||||
|
||||
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
||||
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
|
||||
from lib.common import load_json, save_json
|
||||
|
||||
acme_data = load_json(acme_cfg)
|
||||
acme_data["email"] = email
|
||||
acme_data["ca"] = server
|
||||
save_json(acme_cfg, acme_data)
|
||||
|
||||
logger.info("ACME account registered: %s (%s)", email, server)
|
||||
refresh_state(["acme"])
|
||||
return {"registered": True, "email": email, "ca": server}
|
||||
|
||||
|
||||
@registry.register(DELETE_ACME_ACCOUNT_DEACTIVATE)
|
||||
def deactivate_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /acme/account/deactivate — deactivate the ACME account."""
|
||||
try:
|
||||
_run_acme(["--deactivate-account"])
|
||||
except RuntimeError as exc:
|
||||
logger.warning("acme.sh deactivate failed: %s", exc)
|
||||
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
||||
if acme_cfg.is_file():
|
||||
from lib.common import load_json, save_json
|
||||
|
||||
acme_data = load_json(acme_cfg)
|
||||
acme_data.pop("email", None)
|
||||
acme_data.pop("ca", None)
|
||||
save_json(acme_cfg, acme_data)
|
||||
|
||||
account_conf = _ACME_HOME / ".account.conf"
|
||||
if account_conf.is_file():
|
||||
account_conf.unlink()
|
||||
account_conf_no_dot = _ACME_HOME / "account.conf"
|
||||
if account_conf_no_dot.is_file():
|
||||
account_conf_no_dot.unlink()
|
||||
|
||||
logger.info("ACME account deactivated")
|
||||
refresh_state(["acme"])
|
||||
return {"email": ""}
|
||||
|
||||
Reference in New Issue
Block a user