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:
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user