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
This commit is contained in:
Brian Krabach
2026-04-03 23:03:56 -07:00
parent 33b9ebb392
commit 708dffe20e
2 changed files with 189 additions and 0 deletions
+93
View File
@@ -380,3 +380,96 @@ def test_generate_tailscale_returns_none_on_failure(monkeypatch, tmp_path):
assert result is None, ( assert result is None, (
f"generate_tailscale() must return None on non-zero exit, got: {result!r}" 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}"
)
+96
View File
@@ -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: def get_cert_info(cert_path) -> dict | None:
"""Inspect a PEM certificate and return metadata. """Inspect a PEM certificate and return metadata.