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:
|
def setup_tls(method: str = "auto") -> None:
|
||||||
"""Generate TLS certificates and update settings.
|
"""Generate TLS certificates and update settings.
|
||||||
|
|
||||||
For method 'auto' or 'selfsigned': generates a self-signed certificate
|
Auto-detection chain (method='auto'): Tailscale → mkcert → self-signed.
|
||||||
and private key in the muxplex config dir, then updates settings.json with
|
Use --method to force a specific certificate source.
|
||||||
the paths.
|
|
||||||
|
|
||||||
For unknown method: prints an error to stderr and exits with code 1.
|
|
||||||
"""
|
"""
|
||||||
from muxplex.settings import SETTINGS_PATH, patch_settings # noqa: PLC0415
|
from muxplex.settings import SETTINGS_PATH, patch_settings # noqa: PLC0415
|
||||||
from muxplex.tls import generate_self_signed # noqa: PLC0415
|
from muxplex.tls import ( # noqa: PLC0415
|
||||||
|
detect_mkcert,
|
||||||
if method not in ("auto", "selfsigned"):
|
detect_tailscale,
|
||||||
print(
|
generate_mkcert,
|
||||||
f"Error: unknown TLS method '{method}'. Valid: auto, selfsigned",
|
generate_self_signed,
|
||||||
file=sys.stderr,
|
generate_tailscale,
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
config_dir = SETTINGS_PATH.parent
|
config_dir = SETTINGS_PATH.parent
|
||||||
cert_path = config_dir / "muxplex.crt"
|
cert_path = config_dir / "muxplex.crt"
|
||||||
key_path = config_dir / "muxplex.key"
|
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)})
|
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 = (
|
expiry_str = (
|
||||||
info["expires"].strftime("%Y-%m-%d")
|
result["expires"].strftime("%Y-%m-%d")
|
||||||
if hasattr(info["expires"], "strftime")
|
if hasattr(result["expires"], "strftime")
|
||||||
else str(info["expires"])
|
else str(result["expires"])
|
||||||
)
|
)
|
||||||
|
|
||||||
print("TLS setup complete")
|
print("TLS setup complete")
|
||||||
print(" Method: self-signed (selfsigned)")
|
print(f" Certificate: {result['cert_path']}")
|
||||||
print(f" Cert: {info['cert_path']}")
|
print(f" Key: {result['key_path']}")
|
||||||
print(f" Key: {info['key_path']}")
|
|
||||||
print(f" Hostnames: {hostnames_str}")
|
print(f" Hostnames: {hostnames_str}")
|
||||||
print(f" Expires: {expiry_str}")
|
print(f" Expires: {expiry_str}")
|
||||||
print()
|
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()
|
||||||
print(" Restart muxplex to apply TLS settings:")
|
elif method_used == "tailscale":
|
||||||
print(" muxplex service restart")
|
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:
|
def _add_serve_flags(parser: argparse.ArgumentParser) -> None:
|
||||||
@@ -858,7 +914,7 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
setup_tls_parser.add_argument(
|
setup_tls_parser.add_argument(
|
||||||
"--method",
|
"--method",
|
||||||
choices=["auto", "selfsigned"],
|
choices=["auto", "tailscale", "mkcert", "selfsigned"],
|
||||||
default="auto",
|
default="auto",
|
||||||
help="Certificate generation method (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 — "
|
"serve() must NOT pass ssl_certfile to uvicorn when tls_key is empty string — "
|
||||||
"SSL requires both cert and key"
|
"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