1import platform
  2import shutil
  3import subprocess
  4import sys
  5import time
  6import urllib.request
  7from pathlib import Path
  8
  9import click
 10
 11
 12class MkcertManager:
 13    def __init__(self) -> None:
 14        self.mkcert_bin: str | None = None
 15
 16    def setup_mkcert(
 17        self, install_path: Path, *, force_reinstall: bool = False
 18    ) -> None:
 19        """Set up mkcert by checking if it's installed or downloading the binary and installing the local CA."""
 20        if mkcert_path := shutil.which("mkcert"):
 21            self.mkcert_bin = mkcert_path
 22            # Run install if CA files don't exist, or if force reinstall
 23            if force_reinstall or not self._ca_files_exist():
 24                self.install_ca()
 25            return
 26
 27        # mkcert not found system-wide, download to install_path
 28        install_path.mkdir(parents=True, exist_ok=True)
 29        binary_path = install_path / "mkcert"
 30
 31        if force_reinstall and binary_path.exists():
 32            click.secho("Removing existing mkcert binary...", bold=True)
 33            binary_path.unlink()
 34
 35        if not binary_path.exists():
 36            self._download_mkcert(binary_path)
 37
 38        self.mkcert_bin = str(binary_path)
 39
 40        # Run install if CA files don't exist, or if force reinstall
 41        if force_reinstall or not self._ca_files_exist():
 42            self.install_ca()
 43
 44    def _download_mkcert(self, dest: Path) -> None:
 45        """Download the mkcert binary."""
 46        system = platform.system()
 47        machine = platform.machine().lower()
 48
 49        # Map platform.machine() to mkcert's expected architecture strings
 50        arch_map = {
 51            "x86_64": "amd64",
 52            "amd64": "amd64",
 53            "arm64": "arm64",
 54            "aarch64": "arm64",
 55        }
 56        arch = arch_map.get(machine, "amd64")
 57
 58        os_map = {
 59            "Darwin": "darwin",
 60            "Linux": "linux",
 61            "Windows": "windows",
 62        }
 63        os_name = os_map.get(system)
 64        if not os_name:
 65            click.secho(f"Unsupported OS: {system}", fg="red")
 66            sys.exit(1)
 67
 68        mkcert_url = f"https://dl.filippo.io/mkcert/latest?for={os_name}/{arch}"
 69        click.secho(f"Downloading mkcert from {mkcert_url}...", bold=True)
 70        urllib.request.urlretrieve(mkcert_url, dest)
 71        dest.chmod(0o755)
 72
 73    def _get_ca_root(self) -> Path | None:
 74        """Get the mkcert CAROOT directory."""
 75        if not self.mkcert_bin:
 76            return None
 77        result = subprocess.run(
 78            [self.mkcert_bin, "-CAROOT"],
 79            capture_output=True,
 80            text=True,
 81        )
 82        if result.returncode == 0:
 83            return Path(result.stdout.strip())
 84        return None
 85
 86    def _ca_files_exist(self) -> bool:
 87        """Check if the CA root files exist."""
 88        ca_root = self._get_ca_root()
 89        if not ca_root:
 90            return False
 91        return (ca_root / "rootCA.pem").exists() and (
 92            ca_root / "rootCA-key.pem"
 93        ).exists()
 94
 95    def install_ca(self) -> None:
 96        """Install the mkcert CA into the system trust store.
 97
 98        Running `mkcert -install` is idempotent - if already installed,
 99        it just prints a message without prompting for a password.
100        """
101        if not self.mkcert_bin:
102            return
103
104        # Don't capture output so user can see messages and respond to password prompts
105        result = subprocess.run([self.mkcert_bin, "-install"])
106
107        if result.returncode != 0:
108            click.secho("Failed to install mkcert CA", fg="red")
109            raise SystemExit(1)
110
111    def generate_certs(
112        self, domain: str, storage_path: Path, *, force_regenerate: bool = False
113    ) -> tuple[Path, Path]:
114        cert_path = storage_path / f"{domain}-cert.pem"
115        key_path = storage_path / f"{domain}-key.pem"
116        timestamp_path = storage_path / f"{domain}.timestamp"
117        update_interval = 60 * 24 * 3600  # 60 days in seconds
118
119        # Check if the certs exist and if the timestamp is recent enough
120        if not force_regenerate:
121            if cert_path.exists() and key_path.exists() and timestamp_path.exists():
122                last_updated = timestamp_path.stat().st_mtime
123                if time.time() - last_updated < update_interval:
124                    return cert_path, key_path
125
126        storage_path.mkdir(parents=True, exist_ok=True)
127
128        if not self.mkcert_bin:
129            raise RuntimeError("mkcert is not set up. Call setup_mkcert first.")
130
131        click.secho(f"Generating SSL certificates for {domain}...", bold=True)
132        subprocess.run(
133            [
134                self.mkcert_bin,
135                "-cert-file",
136                str(cert_path),
137                "-key-file",
138                str(key_path),
139                domain,
140            ],
141            check=True,
142        )
143
144        # Update the timestamp file to the current time
145        with open(timestamp_path, "w") as f:
146            f.write(str(time.time()))
147
148        return cert_path, key_path