feat: auto-detection chain in setup_tls() — Tailscale → mkcert → self-signed

Rewrite setup_tls() with full auto-detection chain:
1. Tailscale: detect via detect_tailscale(), generate cert with generate_tailscale()
2. mkcert: detect via detect_mkcert(), generate cert with generate_mkcert()
   - Includes optional Tailscale SANs as extra_hostnames when available
3. Self-signed: fallback via generate_self_signed()

For forced methods (--method tailscale/mkcert), exits with code 1 on failure.
Prints cert info (Certificate, Key, Hostnames, Expires) on success.
Method-specific warnings: self-signed (browser warning), tailscale (90-day expiry).
Always prints 'Restart service to apply: muxplex service restart'.

Update argparse --method choices to include 'tailscale' and 'mkcert'.

Add 4 tests:
- test_setup_tls_auto_uses_tailscale_when_available
- test_setup_tls_auto_falls_to_mkcert_when_no_tailscale
- test_setup_tls_auto_falls_to_selfsigned_when_nothing_available
- test_setup_tls_method_choices_expanded

Co-authored-by: Amplifier <amplifier@bkrabach.com>
This commit is contained in:
Brian Krabach
2026-04-03 23:13:50 -07:00
parent 708dffe20e
commit 3bba2d44e2
2 changed files with 224 additions and 29 deletions
+85 -29
View File
@@ -715,49 +715,105 @@ def config_reset(key: str | None = None) -> None:
def setup_tls(method: str = "auto") -> None:
"""Generate TLS certificates and update settings.
For method 'auto' or 'selfsigned': generates a self-signed certificate
and private key in the muxplex config dir, then updates settings.json with
the paths.
For unknown method: prints an error to stderr and exits with code 1.
Auto-detection chain (method='auto'): Tailscale → mkcert → self-signed.
Use --method to force a specific certificate source.
"""
from muxplex.settings import SETTINGS_PATH, patch_settings # noqa: PLC0415
from muxplex.tls import generate_self_signed # noqa: PLC0415
if method not in ("auto", "selfsigned"):
print(
f"Error: unknown TLS method '{method}'. Valid: auto, selfsigned",
file=sys.stderr,
)
sys.exit(1)
from muxplex.tls import ( # noqa: PLC0415
detect_mkcert,
detect_tailscale,
generate_mkcert,
generate_self_signed,
generate_tailscale,
)
config_dir = SETTINGS_PATH.parent
cert_path = config_dir / "muxplex.crt"
key_path = config_dir / "muxplex.key"
info = generate_self_signed(cert_path, key_path)
result = None
tailscale_info = None
# Step 1: Try Tailscale
if method in ("auto", "tailscale"):
tailscale_info = detect_tailscale()
if tailscale_info:
hostname = tailscale_info["hostname"]
print(f" Detected Tailscale: {hostname}")
result = generate_tailscale(cert_path, key_path, hostname)
if result:
print(" Tailscale certificate obtained")
else:
print(" Tailscale certificate generation failed")
if method == "tailscale" and result is None:
print(
"Error: Tailscale not available or certificate generation failed",
file=sys.stderr,
)
sys.exit(1)
# Step 2: Try mkcert
if result is None and method in ("auto", "mkcert"):
if detect_mkcert():
print(" Detected mkcert, generating certificate...")
extra_hostnames = None
if tailscale_info:
extra_hostnames = tailscale_info.get("cert_domains") or None
result = generate_mkcert(
cert_path, key_path, extra_hostnames=extra_hostnames
)
else:
if method == "mkcert":
print(
"Error: mkcert not found. Install from https://mkcert.dev",
file=sys.stderr,
)
sys.exit(1)
# Step 3: Try self-signed
if result is None and method in ("auto", "selfsigned"):
result = generate_self_signed(cert_path, key_path)
# Step 4: Final failure check
if result is None:
print(
"Error: TLS certificate generation failed with all methods",
file=sys.stderr,
)
sys.exit(1)
# Update settings with cert/key paths
patch_settings({"tls_cert": str(cert_path), "tls_key": str(key_path)})
hostnames_str = ", ".join(info["hostnames"])
# Print cert info
hostnames_str = ", ".join(result["hostnames"])
expiry_str = (
info["expires"].strftime("%Y-%m-%d")
if hasattr(info["expires"], "strftime")
else str(info["expires"])
result["expires"].strftime("%Y-%m-%d")
if hasattr(result["expires"], "strftime")
else str(result["expires"])
)
print("TLS setup complete")
print(" Method: self-signed (selfsigned)")
print(f" Cert: {info['cert_path']}")
print(f" Key: {info['key_path']}")
print(f" Hostnames: {hostnames_str}")
print(f" Expires: {expiry_str}")
print(f" Certificate: {result['cert_path']}")
print(f" Key: {result['key_path']}")
print(f" Hostnames: {hostnames_str}")
print(f" Expires: {expiry_str}")
print()
print(" Note: Browsers will show a security warning for self-signed certificates.")
print(" You can accept the warning or add the cert to your system trust store.")
print()
print(" Restart muxplex to apply TLS settings:")
print(" muxplex service restart")
# Method-specific warnings
method_used = result.get("method", "")
if method_used == "selfsigned":
print(
" Note: Browsers will show a security warning for self-signed certificates."
)
print(" Consider using mkcert or Tailscale for a trusted certificate.")
print()
elif method_used == "tailscale":
print(" Note: Tailscale certificates expire after 90 days.")
print(" Run 'muxplex setup-tls' to renew.")
print()
print(" Restart service to apply: muxplex service restart")
def _add_serve_flags(parser: argparse.ArgumentParser) -> None:
@@ -858,7 +914,7 @@ def main() -> None:
)
setup_tls_parser.add_argument(
"--method",
choices=["auto", "selfsigned"],
choices=["auto", "tailscale", "mkcert", "selfsigned"],
default="auto",
help="Certificate generation method (default: auto)",
)
+139
View File
@@ -1667,3 +1667,142 @@ def test_serve_no_ssl_when_only_cert_set(tmp_path, monkeypatch, capsys):
"serve() must NOT pass ssl_certfile to uvicorn when tls_key is empty string — "
"SSL requires both cert and key"
)
# ---------------------------------------------------------------------------
# task-4: Auto-detection chain tests for setup_tls()
# ---------------------------------------------------------------------------
def test_setup_tls_auto_uses_tailscale_when_available(tmp_path, monkeypatch, capsys):
"""setup_tls(method='auto') uses Tailscale when detect_tailscale() returns info."""
from datetime import datetime, timezone
import muxplex.settings as settings_mod
import muxplex.tls as tls_mod
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
ts_hostname = "myhost.tailscale.net"
ts_info = {
"hostname": ts_hostname,
"ips": ["100.0.0.1"],
"cert_domains": [ts_hostname],
}
fake_expires = datetime(2025, 12, 31, tzinfo=timezone.utc)
ts_result = {
"method": "tailscale",
"cert_path": str(tmp_path / "muxplex.crt"),
"key_path": str(tmp_path / "muxplex.key"),
"hostnames": [ts_hostname],
"expires": fake_expires,
}
monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: ts_info)
monkeypatch.setattr(tls_mod, "generate_tailscale", lambda cp, kp, h: ts_result)
from muxplex.cli import setup_tls
setup_tls(method="auto")
out = capsys.readouterr().out
assert "tailscale" in out.lower(), f"Expected 'tailscale' in output, got: {out!r}"
def test_setup_tls_auto_falls_to_mkcert_when_no_tailscale(
tmp_path, monkeypatch, capsys
):
"""setup_tls(method='auto') falls back to mkcert when Tailscale not available."""
from datetime import datetime, timezone
import muxplex.settings as settings_mod
import muxplex.tls as tls_mod
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
fake_expires = datetime(2025, 12, 31, tzinfo=timezone.utc)
mkcert_result = {
"method": "mkcert",
"cert_path": str(tmp_path / "muxplex.crt"),
"key_path": str(tmp_path / "muxplex.key"),
"hostnames": ["localhost"],
"expires": fake_expires,
}
monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: True)
monkeypatch.setattr(
tls_mod,
"generate_mkcert",
lambda cp, kp, extra_hostnames=None: mkcert_result,
)
from muxplex.cli import setup_tls
setup_tls(method="auto")
out = capsys.readouterr().out
assert "mkcert" in out.lower(), f"Expected 'mkcert' in output, got: {out!r}"
def test_setup_tls_auto_falls_to_selfsigned_when_nothing_available(
tmp_path, monkeypatch, capsys
):
"""setup_tls(method='auto') falls back to self-signed when nothing else is available."""
from datetime import datetime, timezone
import muxplex.settings as settings_mod
import muxplex.tls as tls_mod
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
fake_expires = datetime(2025, 12, 31, tzinfo=timezone.utc)
selfsigned_result = {
"method": "selfsigned",
"cert_path": str(tmp_path / "muxplex.crt"),
"key_path": str(tmp_path / "muxplex.key"),
"hostnames": ["localhost"],
"expires": fake_expires,
}
monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: False)
monkeypatch.setattr(
tls_mod, "generate_self_signed", lambda cp, kp: selfsigned_result
)
from muxplex.cli import setup_tls
setup_tls(method="auto")
out = capsys.readouterr().out
out_lower = out.lower()
assert "self-signed" in out_lower or "selfsigned" in out_lower, (
f"Expected 'self-signed' or 'selfsigned' in output, got: {out!r}"
)
def test_setup_tls_method_choices_expanded():
"""setup-tls --help must show 'tailscale' and 'mkcert' as method choices."""
import io
from muxplex.cli import main
buf = io.StringIO()
with patch("sys.argv", ["muxplex", "setup-tls", "--help"]):
try:
with patch("sys.stdout", buf):
main()
except SystemExit:
pass
help_text = buf.getvalue()
assert "tailscale" in help_text, (
f"Expected 'tailscale' in setup-tls --help output, got:\n{help_text}"
)
assert "mkcert" in help_text, (
f"Expected 'mkcert' in setup-tls --help output, got:\n{help_text}"
)