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
This commit is contained in:
Brian Krabach
2026-04-03 22:56:45 -07:00
parent 1e20d5b131
commit 33b9ebb392
2 changed files with 118 additions and 0 deletions
+59
View File
@@ -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}"
)
+59
View File
@@ -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.