Initial commit: SSL proxy / firewall appliance
Flask WebUI behind nginx reverse proxy with zone-based firewall, DHCP, WireGuard, and ACME certificate management.
This commit is contained in:
+510
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
ACME certificate manager for Vacuum Wall.
|
||||
|
||||
Wraps acme.sh to issue, renew, and manage SSL/TLS certificates
|
||||
from Let's Encrypt (or other ACME providers).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ACME_ENVIRON = {
|
||||
"HOME": str(Path.home()),
|
||||
"PATH": os.environ.get(
|
||||
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _find_acme() -> str:
|
||||
"""Locate the acme.sh binary on the system.
|
||||
|
||||
Checks:
|
||||
1. ~/.acme.sh/acme.sh
|
||||
2. /usr/local/bin/acme.sh
|
||||
|
||||
Returns:
|
||||
Absolute path to the acme.sh binary.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If acme.sh cannot be found.
|
||||
"""
|
||||
candidates = [
|
||||
Path.home() / ".acme.sh" / "acme.sh",
|
||||
Path("/usr/local/bin/acme.sh"),
|
||||
]
|
||||
|
||||
for path in candidates:
|
||||
if path.is_file() and os.access(path, os.X_OK):
|
||||
logger.info("Found acme.sh at %s", path)
|
||||
return str(path)
|
||||
|
||||
acme = shutil.which("acme.sh")
|
||||
if acme:
|
||||
logger.info("Found acme.sh via PATH at %s", acme)
|
||||
return acme
|
||||
|
||||
raise FileNotFoundError(
|
||||
"acme.sh not found in any standard location. "
|
||||
"Install it with: curl -sSL https://get.acme.sh | sh"
|
||||
)
|
||||
|
||||
|
||||
def _run_acme(args: list[str]) -> str:
|
||||
"""Execute acme.sh with the given arguments.
|
||||
|
||||
Runs the command as root via sudo because standalone / webroot
|
||||
validation often requires binding to privileged ports (80/443).
|
||||
|
||||
Args:
|
||||
args: List of arguments to pass to acme.sh.
|
||||
|
||||
Returns:
|
||||
Combined stdout + stderr from the command, since acme.sh writes
|
||||
meaningful output to both streams.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the acme.sh command exits with a non-zero code.
|
||||
"""
|
||||
acme_bin = _find_acme()
|
||||
|
||||
cmd: list[str] = [
|
||||
"sudo",
|
||||
acme_bin,
|
||||
"--home",
|
||||
str(Path.home() / ".acme.sh"),
|
||||
"--config-home",
|
||||
str(Path.home() / ".acme.sh"),
|
||||
*args,
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, **_ACME_ENVIRON},
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(
|
||||
f"acme.sh command timed out after 120s: {' '.join(cmd)}"
|
||||
) from exc
|
||||
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output = output + result.stderr if output else result.stderr
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip())
|
||||
raise RuntimeError(
|
||||
f"acme.sh failed with exit code {result.returncode}: {output.strip()}"
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def set_email(email: str) -> None:
|
||||
"""Configure the default ACME contact email.
|
||||
|
||||
Registers or updates the ACME account with the given email address.
|
||||
|
||||
Args:
|
||||
email: The contact email for the ACME account.
|
||||
"""
|
||||
_run_acme(["--register-account", "-m", email])
|
||||
logger.info("ACME contact email set to %s", email)
|
||||
|
||||
|
||||
def get_email() -> str:
|
||||
"""Return the ACME contact email, or '' if none is configured."""
|
||||
try:
|
||||
account_conf = Path.home() / ".acme.sh" / "account.conf"
|
||||
if account_conf.is_file():
|
||||
text = account_conf.read_text()
|
||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip().strip("'\"")
|
||||
except OSError as exc:
|
||||
logger.warning("Could not read account.conf: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def issue(domain: str, webroot: str | None = None, standalone: bool = False) -> dict:
|
||||
"""Issue a new SSL certificate for a domain.
|
||||
|
||||
Args:
|
||||
domain: The primary domain name.
|
||||
webroot: Path to the web root directory for HTTP-01 validation.
|
||||
standalone: If True, use standalone TCP validation (binds port 80).
|
||||
|
||||
Returns:
|
||||
A dict with 'success', 'domain', 'message', 'output', and 'error'.
|
||||
"""
|
||||
args: list[str] = ["--issue", "-d", domain]
|
||||
|
||||
if webroot:
|
||||
args.extend(["--webroot", webroot])
|
||||
elif standalone:
|
||||
args.append("--standalone")
|
||||
|
||||
email = get_email()
|
||||
if email:
|
||||
args.extend(["-m", email])
|
||||
args.append("--force")
|
||||
|
||||
try:
|
||||
output = _run_acme(args)
|
||||
return {
|
||||
"success": True,
|
||||
"domain": domain,
|
||||
"message": f"Certificate for {domain} issued successfully",
|
||||
"output": output.strip(),
|
||||
"error": None,
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"domain": domain,
|
||||
"message": f"Failed to issue certificate for {domain}",
|
||||
"output": "",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def renew(domain: str, force: bool = False) -> dict:
|
||||
"""Renew an existing SSL certificate.
|
||||
|
||||
Args:
|
||||
domain: The domain whose certificate should be renewed.
|
||||
force: If True, renew even if the certificate isn't close to expiry.
|
||||
|
||||
Returns:
|
||||
A dict with 'success', 'domain', 'message', 'output', and 'error'.
|
||||
"""
|
||||
args: list[str] = ["--renew", "-d", domain]
|
||||
if force:
|
||||
args.append("--force")
|
||||
|
||||
try:
|
||||
output = _run_acme(args)
|
||||
return {
|
||||
"success": True,
|
||||
"domain": domain,
|
||||
"message": f"Certificate for {domain} renewed successfully",
|
||||
"output": output.strip(),
|
||||
"error": None,
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"domain": domain,
|
||||
"message": f"Failed to renew certificate for {domain}",
|
||||
"output": "",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def remove(domain: str) -> str:
|
||||
"""Stop auto-renewal for a domain.
|
||||
|
||||
Runs ``acme.sh --remove`` which stops the cron job from renewing
|
||||
the certificate. Per the acme.sh README the cert/key files are
|
||||
**not** deleted from disk after ``--remove``; remove them with
|
||||
the ``--ecc`` flag if needed, or delete the ``~/.acme.sh/{domain}``
|
||||
directory manually.
|
||||
|
||||
Args:
|
||||
domain: The domain to remove from the renewal list.
|
||||
|
||||
Returns:
|
||||
The combined stdout from the acme.sh command.
|
||||
"""
|
||||
output = _run_acme(["--remove", "-d", domain])
|
||||
logger.info("Certificate for %s removed", domain)
|
||||
return output
|
||||
|
||||
|
||||
def list_certs() -> list[dict]:
|
||||
"""List all managed certificates with expiry information.
|
||||
|
||||
Returns:
|
||||
A list of dicts, one per certificate, with keys matching
|
||||
the cert-info schema (domain, ca, cert_path, etc.).
|
||||
"""
|
||||
raw = _run_acme(["--list"])
|
||||
certs: list[dict] = []
|
||||
|
||||
entries = _parse_list_output(raw)
|
||||
acme_home = Path.home() / ".acme.sh"
|
||||
|
||||
for entry in entries:
|
||||
main = entry["main_domain"]
|
||||
if not main:
|
||||
continue
|
||||
|
||||
san_domains = [
|
||||
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
|
||||
]
|
||||
|
||||
cert_dir = acme_home / main
|
||||
cert_path = str(cert_dir / "fullchain.cer")
|
||||
key_path = str(cert_dir / f"{main}.key")
|
||||
ca_path = str(cert_dir / "ca.cer")
|
||||
|
||||
days = _days_until(entry.get("certificate_expires", ""))
|
||||
auto = _has_auto_renew(main)
|
||||
|
||||
certs.append(
|
||||
{
|
||||
"domain": main,
|
||||
"ca": entry.get("CA", ""),
|
||||
"cert_path": cert_path,
|
||||
"key_path": key_path,
|
||||
"ca_path": ca_path,
|
||||
"issued_at": entry.get("certificate_date", ""),
|
||||
"expires_at": entry.get("certificate_expires", ""),
|
||||
"days_until_expiry": days,
|
||||
"auto_renew": auto,
|
||||
"san_domains": san_domains,
|
||||
}
|
||||
)
|
||||
|
||||
return certs
|
||||
|
||||
|
||||
def get_cert_info(domain: str) -> dict:
|
||||
"""Return detailed information about a certificate.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
A dict matching the cert-info schema.
|
||||
|
||||
Raises:
|
||||
ValueError: If no certificate is found for the domain.
|
||||
"""
|
||||
certs = list_certs()
|
||||
for c in certs:
|
||||
if c["domain"] == domain or domain in c["san_domains"]:
|
||||
return c
|
||||
|
||||
raise ValueError(f"No certificate found for domain: {domain}")
|
||||
|
||||
|
||||
def get_expiry(domain: str) -> str | None:
|
||||
"""Return the certificate expiry date as an ISO string, or None.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
Expiry date string (e.g. '2026-04-15') or None.
|
||||
"""
|
||||
try:
|
||||
info = get_cert_info(domain)
|
||||
return info.get("expires_at")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_expired(domain: str) -> bool:
|
||||
"""Check whether a certificate has expired.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
True if the certificate is expired or not found, False otherwise.
|
||||
"""
|
||||
days = days_until_expiry(domain)
|
||||
if days is None:
|
||||
return True
|
||||
return days < 0
|
||||
|
||||
|
||||
def days_until_expiry(domain: str) -> int | None:
|
||||
"""Calculate the number of days until a certificate expires.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
Integer days remaining (negative if expired), or None if cert not found.
|
||||
"""
|
||||
try:
|
||||
info = get_cert_info(domain)
|
||||
return _days_until(info.get("expires_at", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def copy_cert(domain: str, dest_dir: str) -> dict:
|
||||
"""Copy certificate files to a target directory.
|
||||
|
||||
Copies the fullchain, key, and CA certificate files.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
dest_dir: Destination directory path.
|
||||
|
||||
Returns:
|
||||
A dict with paths to the copied files.
|
||||
"""
|
||||
paths = get_cert_paths(domain)
|
||||
target = Path(dest_dir)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copied = {}
|
||||
for label, src in paths.items():
|
||||
src_path = Path(src)
|
||||
if src_path.is_file():
|
||||
dst = target / src_path.name
|
||||
shutil.copy2(str(src_path), str(dst))
|
||||
copied[label] = str(dst)
|
||||
else:
|
||||
logger.warning("Source %s (%s) not found, skipping", label, src)
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"dest_dir": str(target),
|
||||
"copied": copied,
|
||||
"failed": [k for k in paths if k not in copied],
|
||||
}
|
||||
|
||||
|
||||
def get_cert_paths(domain: str) -> dict:
|
||||
"""Return the file paths for all certificate components.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
|
||||
Returns:
|
||||
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
|
||||
"""
|
||||
acme_home = str(Path.home() / ".acme.sh" / 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",
|
||||
}
|
||||
|
||||
|
||||
def setup_nginx_install(domain: str) -> None:
|
||||
"""Configure acme.sh to automatically install certs for nginx.
|
||||
|
||||
Sets up a post-hook so that nginx-specific files are copied to
|
||||
/etc/ssl/certs and /etc/ssl/private after each (re)issue, followed
|
||||
by an nginx reload.
|
||||
|
||||
Args:
|
||||
domain: The domain name.
|
||||
"""
|
||||
cert_dest = f"/etc/ssl/certs/{domain}"
|
||||
key_dest = f"/etc/ssl/private/{domain}.key"
|
||||
|
||||
args: list[str] = [
|
||||
"--install-cert",
|
||||
"-d",
|
||||
domain,
|
||||
"--cert-file",
|
||||
cert_dest,
|
||||
"--key-file",
|
||||
key_dest,
|
||||
"--ca-file",
|
||||
f"/etc/ssl/certs/{domain}-ca.crt",
|
||||
"--fullchain-file",
|
||||
f"/etc/ssl/certs/{domain}-fullchain.crt",
|
||||
"--reloadcmd",
|
||||
"sudo nginx -t && sudo systemctl reload nginx",
|
||||
]
|
||||
|
||||
_run_acme(args)
|
||||
logger.info("nginx auto-install configured for %s", domain)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_list_output(raw: str) -> list[dict]:
|
||||
"""Parse the text output from ``acme.sh --list`` into a list of dicts.
|
||||
|
||||
Each line in the output contains ``Key:Value`` tokens separated by
|
||||
whitespace, e.g.::
|
||||
|
||||
Main_Domain:example.com SAN_Domain:www.example.com CA:Let's
|
||||
Encrypt Certificate_Date:2026-04-01 Certificate_Expired:No
|
||||
|
||||
Keys are converted to lowercase in the returned dicts.
|
||||
"""
|
||||
entries: list[dict] = []
|
||||
for line in raw.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
entry: dict[str, str] = {}
|
||||
for token in line.split():
|
||||
if ":" not in token:
|
||||
continue
|
||||
key, _, value = token.partition(":")
|
||||
entry[key.lower()] = value
|
||||
if entry:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
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", "%Y%m%d%H%M%z"):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
|
||||
delta = dt - datetime.now(UTC)
|
||||
return delta.days
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _has_auto_renew(domain: str) -> bool:
|
||||
"""Check whether a domain has a scheduled cron renewal.
|
||||
|
||||
The README states the cron entry format is:
|
||||
0 0 * * * "~/.acme.sh"/acme.sh --cron --home "~/.acme.sh" > /dev/null
|
||||
A per-domain ``{domain}.conf`` file existing under ``~/.acme.sh/``
|
||||
indicates the domain is being tracked by the cron job.
|
||||
"""
|
||||
acme_home = Path.home() / ".acme.sh"
|
||||
|
||||
# The cron job iterates all domains tracked in ~/.acme.sh/; if the
|
||||
# per-domain config exists, the cron will pick it up.
|
||||
domain_conf = acme_home / f"{domain}.conf"
|
||||
if domain_conf.is_file():
|
||||
return True
|
||||
|
||||
# Fallback: check crontab -l for the domain.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "crontab", "-l"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and "--cron" in result.stdout:
|
||||
return True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return False
|
||||
Reference in New Issue
Block a user