diff --git a/muxplex/cli.py b/muxplex/cli.py index f7476cb..902e0aa 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -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)", ) diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index 75d87f2..19d8033 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -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}" + )