From 708dffe20efca269fd69120044de1109cadfb147 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Fri, 3 Apr 2026 23:03:56 -0700 Subject: [PATCH] feat: add detect_mkcert() and generate_mkcert() for locally-trusted CA certificates - detect_mkcert() returns True if mkcert is on PATH via shutil.which - generate_mkcert() runs mkcert -install then generates cert for hostname, hostname.local, localhost, 127.0.0.1, ::1, plus any extra_hostnames; deduplicates preserving order; sets key permissions to 0o600 - Returns None on install failure, subprocess error, or missing output files - Added 4 tests: detect true/false, generate success/install-fails --- muxplex/tests/test_tls.py | 93 +++++++++++++++++++++++++++++++++++++ muxplex/tls.py | 96 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/muxplex/tests/test_tls.py b/muxplex/tests/test_tls.py index 4f8ac51..049f8a6 100644 --- a/muxplex/tests/test_tls.py +++ b/muxplex/tests/test_tls.py @@ -380,3 +380,96 @@ def test_generate_tailscale_returns_none_on_failure(monkeypatch, tmp_path): assert result is None, ( f"generate_tailscale() must return None on non-zero exit, got: {result!r}" ) + + +# --------------------------------------------------------------------------- +# 19-22. detect_mkcert() and generate_mkcert() tests +# --------------------------------------------------------------------------- + + +def test_detect_mkcert_returns_true_when_installed(monkeypatch): + """detect_mkcert() must return True when mkcert is on PATH.""" + from muxplex.tls import detect_mkcert + + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/local/bin/mkcert" if name == "mkcert" else None, + ) + + result = detect_mkcert() + + assert result is True, ( + f"detect_mkcert() must return True when mkcert is installed, got: {result!r}" + ) + + +def test_detect_mkcert_returns_false_when_not_installed(monkeypatch): + """detect_mkcert() must return False when mkcert is not on PATH.""" + from muxplex.tls import detect_mkcert + + monkeypatch.setattr("shutil.which", lambda name: None) + + result = detect_mkcert() + + assert result is False, ( + f"detect_mkcert() must return False when mkcert is not installed, got: {result!r}" + ) + + +def test_generate_mkcert_calls_mkcert_install_and_generate(monkeypatch, tmp_path): + """generate_mkcert() must call 'mkcert -install' then 'mkcert -cert-file ...' and return result dict.""" + from muxplex.tls import generate_mkcert, generate_self_signed + + cert_path = tmp_path / "cert.pem" + key_path = tmp_path / "key.pem" + + calls = [] + + def fake_run(cmd, *args, **kwargs): + calls.append(cmd) + # If '-cert-file' is in the command, create fake cert/key files + if "-cert-file" in cmd: + generate_self_signed(cert_path, key_path, hostnames=["localhost"]) + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr("subprocess.run", fake_run) + + result = generate_mkcert(cert_path, key_path) + + # Verify '-install' was called + install_calls = [c for c in calls if "-install" in c] + assert len(install_calls) > 0, ( + f"'mkcert -install' must have been called, got calls: {calls!r}" + ) + + # Verify '-cert-file' was called + cert_calls = [c for c in calls if "-cert-file" in c] + assert len(cert_calls) > 0, ( + f"'mkcert -cert-file' must have been called, got calls: {calls!r}" + ) + + assert result is not None, "generate_mkcert() must return a dict on success" + assert result["method"] == "mkcert", ( + f"result['method'] must be 'mkcert', got: {result.get('method')!r}" + ) + + +def test_generate_mkcert_falls_back_when_install_fails(monkeypatch, tmp_path): + """generate_mkcert() must return None when 'mkcert -install' exits with non-zero.""" + from muxplex.tls import generate_mkcert + + cert_path = tmp_path / "cert.pem" + key_path = tmp_path / "key.pem" + + def fake_run(cmd, *args, **kwargs): + if "-install" in cmd: + return type("R", (), {"returncode": 1, "stdout": "", "stderr": "error"})() + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr("subprocess.run", fake_run) + + result = generate_mkcert(cert_path, key_path) + + assert result is None, ( + f"generate_mkcert() must return None when mkcert -install fails, got: {result!r}" + ) diff --git a/muxplex/tls.py b/muxplex/tls.py index 6adb96d..673f085 100644 --- a/muxplex/tls.py +++ b/muxplex/tls.py @@ -225,6 +225,102 @@ def generate_tailscale(cert_path, key_path, hostname: str) -> dict | None: } +def detect_mkcert() -> bool: + """Return True if mkcert is available on PATH, False otherwise.""" + import shutil + + return shutil.which("mkcert") is not None + + +def generate_mkcert( + cert_path, + key_path, + extra_hostnames: list[str] | None = None, +) -> dict | None: + """Generate a locally-trusted certificate via mkcert. + + Args: + cert_path: Destination path for the certificate PEM file. + key_path: Destination path for the private key PEM file. + extra_hostnames: Additional hostnames to include in the certificate. + + Returns: + dict with keys: method, cert_path, key_path, hostnames, expires. + Returns None on failure (mkcert not found, non-zero exit, timeout, or missing files). + """ + import subprocess + + cert_path = Path(cert_path) + key_path = Path(key_path) + + cert_path.parent.mkdir(parents=True, exist_ok=True) + key_path.parent.mkdir(parents=True, exist_ok=True) + + # Step 1: install the local CA + try: + result = subprocess.run( + ["mkcert", "-install"], + capture_output=True, + text=True, + timeout=30, + ) + except (subprocess.TimeoutExpired, OSError): + return None + + if result.returncode != 0: + return None + + # Step 2: build deduplicated hostname list (preserving order) + hostname = socket.gethostname() + base_hostnames = [hostname, f"{hostname}.local", "localhost", "127.0.0.1", "::1"] + if extra_hostnames: + base_hostnames.extend(extra_hostnames) + + seen: set[str] = set() + unique_hostnames: list[str] = [] + for h in base_hostnames: + if h not in seen: + seen.add(h) + unique_hostnames.append(h) + + # Step 3: generate the certificate + try: + result = subprocess.run( + [ + "mkcert", + "-cert-file", + str(cert_path), + "-key-file", + str(key_path), + *unique_hostnames, + ], + capture_output=True, + text=True, + timeout=30, + ) + except (subprocess.TimeoutExpired, OSError): + return None + + if result.returncode != 0: + return None + + if not cert_path.exists() or not key_path.exists(): + return None + + key_path.chmod(0o600) + + info = get_cert_info(cert_path) + expires = info["expires"] if info else None + + return { + "method": "mkcert", + "cert_path": str(cert_path), + "key_path": str(key_path), + "hostnames": unique_hostnames, + "expires": expires, + } + + def get_cert_info(cert_path) -> dict | None: """Inspect a PEM certificate and return metadata.