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