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
@@ -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.