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:
+81
-25
@@ -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,
|
||||
from muxplex.tls import ( # noqa: PLC0415
|
||||
detect_mkcert,
|
||||
detect_tailscale,
|
||||
generate_mkcert,
|
||||
generate_self_signed,
|
||||
generate_tailscale,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
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" 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.")
|
||||
|
||||
# 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()
|
||||
print(" Restart muxplex to apply TLS settings:")
|
||||
print(" muxplex service restart")
|
||||
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)",
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user