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:
@@ -321,3 +321,62 @@ def test_detect_tailscale_returns_none_when_no_cert_domains(monkeypatch):
|
|||||||
assert result is None, (
|
assert result is None, (
|
||||||
f"detect_tailscale() must return None when CertDomains is empty, got: {result!r}"
|
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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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:
|
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