From 33b9ebb39209ce00f69cc30767440b4b97f8e57b Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Fri, 3 Apr 2026 22:56:45 -0700 Subject: [PATCH] feat: add generate_tailscale() for Tailscale cert generation Add generate_tailscale(cert_path, key_path, hostname) to muxplex/tls.py. The function calls 'tailscale cert' to obtain real Let's Encrypt certificates via the Tailscale CLI. - Creates parent directories with mkdir(parents=True, exist_ok=True) - Runs subprocess with --cert-file and --key-file flags (timeout=30) - Returns None on non-zero exit, TimeoutExpired, OSError, or missing files - On success: sets key_path chmod(0o600), reads cert info, returns metadata dict with method='tailscale', cert_path, key_path, hostnames, expires Tests added: - test_generate_tailscale_calls_tailscale_cert: verifies --cert-file/--key-file flags - test_generate_tailscale_returns_none_on_failure: verifies None on rc=1 --- muxplex/tests/test_tls.py | 59 +++++++++++++++++++++++++++++++++++++++ muxplex/tls.py | 59 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/muxplex/tests/test_tls.py b/muxplex/tests/test_tls.py index 956848a..4f8ac51 100644 --- a/muxplex/tests/test_tls.py +++ b/muxplex/tests/test_tls.py @@ -321,3 +321,62 @@ def test_detect_tailscale_returns_none_when_no_cert_domains(monkeypatch): assert result is None, ( f"detect_tailscale() must return None when CertDomains is empty, got: {result!r}" ) + + +# --------------------------------------------------------------------------- +# 17-18. generate_tailscale() tests +# --------------------------------------------------------------------------- + + +def test_generate_tailscale_calls_tailscale_cert(monkeypatch, tmp_path): + """generate_tailscale() must call 'tailscale cert' with --cert-file and --key-file flags.""" + from muxplex.tls import generate_tailscale, generate_self_signed + + cert_path = tmp_path / "cert.pem" + key_path = tmp_path / "key.pem" + hostname = "myhost.tail1234.ts.net" + + captured_args = {} + + def fake_run(cmd, *args, **kwargs): + captured_args["cmd"] = cmd + # Create cert/key files to simulate success + generate_self_signed(cert_path, key_path, hostnames=[hostname]) + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr("subprocess.run", fake_run) + + result = generate_tailscale(cert_path, key_path, hostname) + + assert "--cert-file" in captured_args["cmd"], ( + f"'--cert-file' must be in subprocess call, got: {captured_args['cmd']!r}" + ) + assert "--key-file" in captured_args["cmd"], ( + f"'--key-file' must be in subprocess call, got: {captured_args['cmd']!r}" + ) + assert result is not None, "generate_tailscale() must return a dict on success" + assert result["method"] == "tailscale", ( + f"result['method'] must be 'tailscale', got: {result.get('method')!r}" + ) + + +def test_generate_tailscale_returns_none_on_failure(monkeypatch, tmp_path): + """generate_tailscale() must return None when 'tailscale cert' exits with non-zero.""" + from muxplex.tls import generate_tailscale + + cert_path = tmp_path / "cert.pem" + key_path = tmp_path / "key.pem" + hostname = "myhost.tail1234.ts.net" + + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: type( + "R", (), {"returncode": 1, "stdout": "", "stderr": "error"} + )(), + ) + + result = generate_tailscale(cert_path, key_path, hostname) + + assert result is None, ( + f"generate_tailscale() must return None on non-zero exit, got: {result!r}" + ) diff --git a/muxplex/tls.py b/muxplex/tls.py index c921735..6adb96d 100644 --- a/muxplex/tls.py +++ b/muxplex/tls.py @@ -166,6 +166,65 @@ def detect_tailscale() -> dict | None: } +def generate_tailscale(cert_path, key_path, hostname: str) -> dict | None: + """Obtain a Let's Encrypt certificate via Tailscale. + + Args: + cert_path: Destination path for the certificate PEM file. + key_path: Destination path for the private key PEM file. + hostname: Tailscale hostname to request a certificate for. + + Returns: + dict with keys: method, cert_path, key_path, hostnames, expires. + Returns None on failure (non-zero exit, timeout, OS error, 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) + + try: + result = subprocess.run( + [ + "tailscale", + "cert", + "--cert-file", + str(cert_path), + "--key-file", + str(key_path), + hostname, + ], + 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) + if info is None: + return None + + return { + "method": "tailscale", + "cert_path": str(cert_path), + "key_path": str(key_path), + "hostnames": info["hostnames"], + "expires": info["expires"], + } + + def get_cert_info(cert_path) -> dict | None: """Inspect a PEM certificate and return metadata.