Files
muxplex/docs/plans/2026-04-03-tls-phase2-autodetect.md
T

39 KiB

TLS Setup — Phase 2: Auto-detection + Tailscale + mkcert

Execution: Use the subagent-driven-development workflow to implement this plan.

Phase 2 of 2. Phase 1 (foundation) must be complete before starting this phase.

Design doc: docs/plans/2026-04-03-tls-setup-design.md

Phase 1 plan: docs/plans/2026-04-03-tls-phase1-foundation.md

Goal: Add Tailscale and mkcert detection to setup-tls, build the auto-detection chain (Tailscale → mkcert → self-signed), add --status display, and handle existing cert detection with regenerate prompts.

Architecture: muxplex/tls.py gains detection functions for Tailscale and mkcert, plus an auto-detection orchestrator. Each detection function uses shutil.which + subprocess.run to probe external tools. The setup_tls() function in cli.py is extended with the full auto chain. --status reuses get_cert_info() from Phase 1.

Tech Stack: Python stdlib (subprocess, shutil, json), cryptography (from Phase 1), external CLIs (tailscale, mkcert).

Prerequisites from Phase 1:

  • muxplex/tls.py exists with generate_self_signed() and get_cert_info()
  • muxplex/cli.py has setup_tls() function and setup-tls subparser
  • tls_cert / tls_key in DEFAULT_SETTINGS
  • serve() handles SSL params
  • Doctor shows TLS status

Scope boundaries:

  • IN this phase: Tailscale detection, mkcert detection, auto-detection chain, --status, existing cert regenerate prompt
  • OUT of scope: Automatic cert renewal cron, Caddy integration, Let's Encrypt DNS-01

Task 1: Tailscale detection in tls.py

Files:

  • Modify: muxplex/tls.py (add detect_tailscale() function)
  • Modify: muxplex/tests/test_tls.py (add Tailscale detection tests)

Step 1: Write failing tests

Append to muxplex/tests/test_tls.py:

# ---------------------------------------------------------------------------
# Tailscale detection tests
# ---------------------------------------------------------------------------


def test_detect_tailscale_returns_info_when_available(monkeypatch):
    """detect_tailscale() returns dict with hostname and ip when Tailscale is connected with MagicDNS."""
    import shutil
    import subprocess
    from muxplex.tls import detect_tailscale

    monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/tailscale" if name == "tailscale" else None)

    fake_status = {
        "Self": {
            "DNSName": "spark-1.tail8f3c4e.ts.net.",
            "TailscaleIPs": ["100.64.0.1", "fd7a:115c:a1e0::1"],
        },
        "CertDomains": ["spark-1.tail8f3c4e.ts.net"],
    }

    def fake_run(cmd, **kw):
        if "status" in cmd:
            return type("R", (), {"returncode": 0, "stdout": json.dumps(fake_status), "stderr": ""})()
        return type("R", (), {"returncode": 1, "stdout": "", "stderr": ""})()

    monkeypatch.setattr(subprocess, "run", fake_run)

    result = detect_tailscale()

    assert result is not None
    assert result["hostname"] == "spark-1.tail8f3c4e.ts.net"
    assert "100.64.0.1" in result["ips"]


def test_detect_tailscale_returns_none_when_not_installed(monkeypatch):
    """detect_tailscale() returns None when tailscale is not in PATH."""
    import shutil
    from muxplex.tls import detect_tailscale

    monkeypatch.setattr(shutil, "which", lambda name: None)

    result = detect_tailscale()
    assert result is None


def test_detect_tailscale_returns_none_when_not_connected(monkeypatch):
    """detect_tailscale() returns None when tailscale status exits non-zero."""
    import shutil
    import subprocess
    from muxplex.tls import detect_tailscale

    monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/tailscale" if name == "tailscale" else None)

    def fake_run(cmd, **kw):
        return type("R", (), {"returncode": 1, "stdout": "", "stderr": "not connected"})()

    monkeypatch.setattr(subprocess, "run", fake_run)

    result = detect_tailscale()
    assert result is None


def test_detect_tailscale_returns_none_when_no_cert_domains(monkeypatch):
    """detect_tailscale() returns None when CertDomains is empty (HTTPS certs not enabled)."""
    import shutil
    import subprocess
    from muxplex.tls import detect_tailscale

    monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/tailscale" if name == "tailscale" else None)

    fake_status = {
        "Self": {
            "DNSName": "spark-1.tail8f3c4e.ts.net.",
            "TailscaleIPs": ["100.64.0.1"],
        },
        "CertDomains": [],
    }

    def fake_run(cmd, **kw):
        return type("R", (), {"returncode": 0, "stdout": json.dumps(fake_status), "stderr": ""})()

    monkeypatch.setattr(subprocess, "run", fake_run)

    result = detect_tailscale()
    assert result is None

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_tls.py::test_detect_tailscale_returns_info_when_available muxplex/tests/test_tls.py::test_detect_tailscale_returns_none_when_not_installed -v

Expected: FAIL — ImportError: cannot import name 'detect_tailscale'.

Step 3: Implement detect_tailscale() in muxplex/tls.py

Add to muxplex/tls.py:

def detect_tailscale() -> dict | None:
    """Detect if Tailscale is running with MagicDNS and HTTPS certs enabled.

    Returns:
        Dict with keys: hostname, ips, cert_domains.
        Returns None if Tailscale is not installed, not connected, or HTTPS certs are not enabled.
    """
    import json
    import shutil
    import subprocess

    if not shutil.which("tailscale"):
        return None

    try:
        result = subprocess.run(
            ["tailscale", "status", "--self", "--json"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode != 0:
            return None

        data = json.loads(result.stdout)
    except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
        return None

    # Check CertDomains — empty means HTTPS certs not enabled in admin console
    cert_domains = data.get("CertDomains", [])
    if not cert_domains:
        return None

    self_info = data.get("Self", {})
    dns_name = self_info.get("DNSName", "").rstrip(".")
    ips = self_info.get("TailscaleIPs", [])

    if not dns_name:
        return None

    return {
        "hostname": dns_name,
        "ips": ips,
        "cert_domains": cert_domains,
    }

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_tls.py -v

Expected: ALL PASS.

Step 5: Commit

cd muxplex && git add muxplex/tls.py muxplex/tests/test_tls.py && git commit -m "feat: add Tailscale detection to tls.py"

Task 2: Tailscale cert generation

Files:

  • Modify: muxplex/tls.py (add generate_tailscale() function)
  • Modify: muxplex/tests/test_tls.py (add Tailscale cert generation tests)

Step 1: Write failing tests

Append to muxplex/tests/test_tls.py:

# ---------------------------------------------------------------------------
# Tailscale cert generation tests
# ---------------------------------------------------------------------------


def test_generate_tailscale_calls_tailscale_cert(tmp_path, monkeypatch):
    """generate_tailscale() calls 'tailscale cert --cert-file ... --key-file ...'."""
    import subprocess
    from muxplex.tls import generate_tailscale

    cert_path = tmp_path / "cert.pem"
    key_path = tmp_path / "key.pem"

    calls = []

    def fake_run(cmd, **kw):
        calls.append(cmd)
        # Create the cert/key files so the function finds them
        cert_path.write_text("-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n")
        key_path.write_text("-----BEGIN RSA PRIVATE KEY-----\nFAKE\n-----END RSA PRIVATE KEY-----\n")
        return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()

    monkeypatch.setattr(subprocess, "run", fake_run)

    result = generate_tailscale(
        cert_path=cert_path,
        key_path=key_path,
        hostname="spark-1.tail8f3c4e.ts.net",
    )

    assert result is not None
    assert result["method"] == "tailscale"
    # Must have called tailscale cert with the right flags
    tailscale_calls = [c for c in calls if "tailscale" in str(c) and "cert" in str(c)]
    assert len(tailscale_calls) > 0
    cmd = tailscale_calls[0]
    assert "--cert-file" in cmd
    assert "--key-file" in cmd


def test_generate_tailscale_returns_none_on_failure(tmp_path, monkeypatch):
    """generate_tailscale() returns None when tailscale cert fails."""
    import subprocess
    from muxplex.tls import generate_tailscale

    def fake_run(cmd, **kw):
        return type("R", (), {"returncode": 1, "stdout": "", "stderr": "ACME error"})()

    monkeypatch.setattr(subprocess, "run", fake_run)

    result = generate_tailscale(
        cert_path=tmp_path / "cert.pem",
        key_path=tmp_path / "key.pem",
        hostname="spark-1.tail8f3c4e.ts.net",
    )
    assert result is None

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_tls.py::test_generate_tailscale_calls_tailscale_cert muxplex/tests/test_tls.py::test_generate_tailscale_returns_none_on_failure -v

Expected: FAIL — ImportError: cannot import name 'generate_tailscale'.

Step 3: Implement generate_tailscale() in muxplex/tls.py

Add to muxplex/tls.py:

def generate_tailscale(
    cert_path: Path | str,
    key_path: Path | str,
    hostname: str,
) -> dict | None:
    """Generate a TLS certificate using Tailscale's built-in ACME integration.

    Args:
        cert_path: Where to write the certificate PEM file.
        key_path: Where to write the private key PEM file.
        hostname: Tailscale MagicDNS hostname (e.g., spark-1.tail8f3c4e.ts.net).

    Returns:
        Dict with keys: method, cert_path, key_path, hostnames, expires.
        Returns None if tailscale cert fails.
    """
    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,
        )
        if result.returncode != 0:
            return None
    except (subprocess.TimeoutExpired, OSError):
        return None

    if not cert_path.is_file() or not key_path.is_file():
        return None

    key_path.chmod(0o600)

    # Read expiry from the generated cert
    info = get_cert_info(cert_path)
    expires = info["expires"] if info else "unknown"
    hostnames = info["hostnames"] if info else [hostname]

    return {
        "method": "tailscale",
        "cert_path": str(cert_path),
        "key_path": str(key_path),
        "hostnames": hostnames,
        "expires": expires,
    }

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_tls.py -v

Expected: ALL PASS.

Step 5: Commit

cd muxplex && git add muxplex/tls.py muxplex/tests/test_tls.py && git commit -m "feat: add Tailscale cert generation to tls.py"

Task 3: mkcert detection and cert generation

Files:

  • Modify: muxplex/tls.py (add detect_mkcert() and generate_mkcert())
  • Modify: muxplex/tests/test_tls.py (add mkcert tests)

Step 1: Write failing tests

Append to muxplex/tests/test_tls.py:

# ---------------------------------------------------------------------------
# mkcert detection and generation tests
# ---------------------------------------------------------------------------


def test_detect_mkcert_returns_true_when_installed(monkeypatch):
    """detect_mkcert() returns True when mkcert is in PATH."""
    import shutil
    from muxplex.tls import detect_mkcert

    monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/mkcert" if name == "mkcert" else None)

    assert detect_mkcert() is True


def test_detect_mkcert_returns_false_when_not_installed(monkeypatch):
    """detect_mkcert() returns False when mkcert is not in PATH."""
    import shutil
    from muxplex.tls import detect_mkcert

    monkeypatch.setattr(shutil, "which", lambda name: None)

    assert detect_mkcert() is False


def test_generate_mkcert_calls_mkcert_install_and_generate(tmp_path, monkeypatch):
    """generate_mkcert() calls 'mkcert -install' then 'mkcert -cert-file ... -key-file ...'."""
    import subprocess
    from muxplex.tls import generate_mkcert

    cert_path = tmp_path / "cert.pem"
    key_path = tmp_path / "key.pem"

    calls = []

    def fake_run(cmd, **kw):
        calls.append(list(cmd))
        if "-cert-file" in cmd:
            # Simulate mkcert creating the files
            cert_path.write_text("-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n")
            key_path.write_text("-----BEGIN RSA PRIVATE KEY-----\nFAKE\n-----END RSA PRIVATE KEY-----\n")
        return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()

    monkeypatch.setattr(subprocess, "run", fake_run)

    result = generate_mkcert(cert_path=cert_path, key_path=key_path)

    assert result is not None
    assert result["method"] == "mkcert"

    # Must have called mkcert -install
    install_calls = [c for c in calls if "-install" in c]
    assert len(install_calls) > 0, "must call mkcert -install"

    # Must have called mkcert with -cert-file and -key-file
    gen_calls = [c for c in calls if "-cert-file" in c]
    assert len(gen_calls) > 0, "must call mkcert with -cert-file"


def test_generate_mkcert_falls_back_when_install_fails(tmp_path, monkeypatch):
    """generate_mkcert() returns None when 'mkcert -install' fails."""
    import subprocess
    from muxplex.tls import generate_mkcert

    def fake_run(cmd, **kw):
        if "-install" in cmd:
            return type("R", (), {"returncode": 1, "stdout": "", "stderr": "no sudo"})()
        return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()

    monkeypatch.setattr(subprocess, "run", fake_run)

    result = generate_mkcert(cert_path=tmp_path / "cert.pem", key_path=tmp_path / "key.pem")
    assert result is None

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_tls.py::test_detect_mkcert_returns_true_when_installed muxplex/tests/test_tls.py::test_generate_mkcert_calls_mkcert_install_and_generate -v

Expected: FAIL — ImportError: cannot import name 'detect_mkcert'.

Step 3: Implement mkcert functions in muxplex/tls.py

Add to muxplex/tls.py:

def detect_mkcert() -> bool:
    """Check if mkcert is installed and available in PATH."""
    import shutil

    return shutil.which("mkcert") is not None


def generate_mkcert(
    cert_path: Path | str,
    key_path: Path | str,
    extra_hostnames: list[str] | None = None,
) -> dict | None:
    """Generate a TLS certificate using mkcert (locally-trusted CA).

    Runs ``mkcert -install`` first to ensure the local CA is in the trust store,
    then generates a cert for localhost, the machine hostname, and any extras.

    Args:
        cert_path: Where to write the certificate PEM file.
        key_path: Where to write the private key PEM file.
        extra_hostnames: Additional hostnames/IPs to include as SANs.

    Returns:
        Dict with keys: method, cert_path, key_path, hostnames, expires.
        Returns None if mkcert -install or cert generation fails.
    """
    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)

    # Step 1: Install local CA (may prompt for sudo/keychain)
    try:
        result = subprocess.run(
            ["mkcert", "-install"],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if result.returncode != 0:
            return None
    except (subprocess.TimeoutExpired, OSError):
        return None

    # Step 2: Build hostname list
    hostname = socket.gethostname()
    hostnames = [hostname, f"{hostname}.local", "localhost", "127.0.0.1", "::1"]
    if extra_hostnames:
        hostnames.extend(extra_hostnames)
    # Deduplicate while preserving order
    seen: set[str] = set()
    unique_hostnames: list[str] = []
    for h in hostnames:
        if h not in seen:
            seen.add(h)
            unique_hostnames.append(h)

    # Step 3: Generate cert
    try:
        result = subprocess.run(
            [
                "mkcert",
                "-cert-file", str(cert_path),
                "-key-file", str(key_path),
                *unique_hostnames,
            ],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if result.returncode != 0:
            return None
    except (subprocess.TimeoutExpired, OSError):
        return None

    if not cert_path.is_file() or not key_path.is_file():
        return None

    key_path.chmod(0o600)

    # Read expiry from the generated cert
    info = get_cert_info(cert_path)
    expires = info["expires"] if info else "unknown"

    return {
        "method": "mkcert",
        "cert_path": str(cert_path),
        "key_path": str(key_path),
        "hostnames": unique_hostnames,
        "expires": expires,
    }

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_tls.py -v

Expected: ALL PASS.

Step 5: Commit

cd muxplex && git add muxplex/tls.py muxplex/tests/test_tls.py && git commit -m "feat: add mkcert detection and cert generation to tls.py"

Task 4: Auto-detection chain in setup_tls()

Files:

  • Modify: muxplex/cli.py (rewrite setup_tls() with full auto-detection)
  • Modify: muxplex/tests/test_cli.py (add auto-detection tests)

Step 1: Write failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# setup-tls auto-detection chain tests
# ---------------------------------------------------------------------------


def test_setup_tls_auto_uses_tailscale_when_available(tmp_path, monkeypatch, capsys):
    """setup_tls(method='auto') uses Tailscale when detect_tailscale returns info."""
    import muxplex.settings as settings_mod
    import muxplex.tls as tls_mod

    fake_config = tmp_path / ".config" / "muxplex"
    fake_config.mkdir(parents=True)
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_config / "settings.json")

    # Mock Tailscale detection as available
    monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: {
        "hostname": "spark-1.tail8f3c4e.ts.net",
        "ips": ["100.64.0.1"],
        "cert_domains": ["spark-1.tail8f3c4e.ts.net"],
    })

    # Mock Tailscale cert generation as successful
    monkeypatch.setattr(tls_mod, "generate_tailscale", lambda **kw: {
        "method": "tailscale",
        "cert_path": str(fake_config / "cert.pem"),
        "key_path": str(fake_config / "key.pem"),
        "hostnames": ["spark-1.tail8f3c4e.ts.net"],
        "expires": "2026-07-03T00:00:00+00:00",
    })

    from muxplex.cli import setup_tls
    setup_tls(method="auto")

    captured = capsys.readouterr()
    assert "tailscale" in captured.out.lower()


def test_setup_tls_auto_falls_to_mkcert_when_no_tailscale(tmp_path, monkeypatch, capsys):
    """setup_tls(method='auto') falls through to mkcert when Tailscale not available."""
    import muxplex.settings as settings_mod
    import muxplex.tls as tls_mod

    fake_config = tmp_path / ".config" / "muxplex"
    fake_config.mkdir(parents=True)
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_config / "settings.json")

    monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
    monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: True)
    monkeypatch.setattr(tls_mod, "generate_mkcert", lambda **kw: {
        "method": "mkcert",
        "cert_path": str(fake_config / "cert.pem"),
        "key_path": str(fake_config / "key.pem"),
        "hostnames": ["myhost", "localhost"],
        "expires": "2028-04-03T00:00:00+00:00",
    })

    from muxplex.cli import setup_tls
    setup_tls(method="auto")

    captured = capsys.readouterr()
    assert "mkcert" in captured.out.lower()


def test_setup_tls_auto_falls_to_selfsigned_when_nothing_available(tmp_path, monkeypatch, capsys):
    """setup_tls(method='auto') falls through to self-signed when nothing else is available."""
    import muxplex.settings as settings_mod
    import muxplex.tls as tls_mod

    fake_config = tmp_path / ".config" / "muxplex"
    fake_config.mkdir(parents=True)
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_config / "settings.json")

    monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
    monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: False)

    from muxplex.cli import setup_tls
    setup_tls(method="auto")

    captured = capsys.readouterr()
    assert "self-signed" in captured.out.lower() or "selfsigned" in captured.out.lower()


def test_setup_tls_method_choices_expanded():
    """setup-tls --method must accept tailscale and mkcert in addition to auto and selfsigned."""
    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
    assert "mkcert" in help_text

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_setup_tls_auto_uses_tailscale_when_available muxplex/tests/test_cli.py::test_setup_tls_method_choices_expanded -v

Expected: FAIL — setup_tls() doesn't know about Tailscale or mkcert yet.

Step 3: Rewrite setup_tls() in muxplex/cli.py

Replace the existing setup_tls() function with:

def setup_tls(method: str = "auto") -> None:
    """Set up TLS certificates for HTTPS.

    Auto-detection chain: Tailscale → mkcert → self-signed.
    """
    from muxplex.settings import SETTINGS_PATH, patch_settings  # noqa: PLC0415
    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 / "cert.pem"
    key_path = config_dir / "key.pem"

    result = None

    if method in ("auto", "tailscale"):
        ts_info = detect_tailscale()
        if ts_info:
            print(f"  Tailscale detected: {ts_info['hostname']}")
            result = generate_tailscale(
                cert_path=cert_path,
                key_path=key_path,
                hostname=ts_info["hostname"],
            )
            if result:
                print(f"\n  TLS configured (Tailscale — real Let's Encrypt cert)")
            else:
                print("  Tailscale cert generation failed.")
                if method == "tailscale":
                    print("  Check: are HTTPS Certificates enabled in your Tailscale admin console?", file=sys.stderr)
                    sys.exit(1)
        elif method == "tailscale":
            print("  Tailscale not detected or HTTPS certs not enabled.", file=sys.stderr)
            print("  Enable HTTPS Certificates in your Tailscale admin console, then re-run.", file=sys.stderr)
            sys.exit(1)

    if result is None and method in ("auto", "mkcert"):
        if detect_mkcert():
            print("  mkcert detected — generating locally-trusted certificate...")
            # If Tailscale is detected, add its hostname/IP as extra SANs
            extra = []
            ts_info = detect_tailscale()
            if ts_info:
                extra.append(ts_info["hostname"])
                extra.extend(ts_info["ips"])
            result = generate_mkcert(
                cert_path=cert_path,
                key_path=key_path,
                extra_hostnames=extra if extra else None,
            )
            if result:
                print(f"\n  TLS configured (mkcert — locally-trusted CA)")
            else:
                print("  mkcert cert generation failed.")
                if method == "mkcert":
                    sys.exit(1)
        elif method == "mkcert":
            print("  mkcert not found in PATH.", file=sys.stderr)
            print("  Install: https://github.com/FiloSottile/mkcert#installation", file=sys.stderr)
            sys.exit(1)

    if result is None and method in ("auto", "selfsigned"):
        result = generate_self_signed(cert_path=cert_path, key_path=key_path)
        print(f"\n  TLS configured (self-signed)")

    if result is None:
        print(f"  Failed to generate TLS certificates with method: {method}", file=sys.stderr)
        sys.exit(1)

    # Update settings with cert paths
    patch_settings({"tls_cert": result["cert_path"], "tls_key": result["key_path"]})

    print(f"  Certificate: {result['cert_path']}")
    print(f"  Key:         {result['key_path']}")
    print(f"  Hostnames:   {', '.join(result['hostnames'])}")
    print(f"  Expires:     {result['expires']}")

    if result["method"] == "selfsigned":
        print()
        print("  Warning: Browsers will show a security warning.")
        print("  For zero-warning HTTPS, install mkcert or use Tailscale.")
    elif result["method"] == "tailscale":
        print()
        print("  Tailscale certs expire in 90 days. Run `muxplex setup-tls` again to renew.")

    print()
    print("  Restart service to apply: muxplex service restart")
    print()

Also update the argparse setup_tls_parser choices in main() to include all methods:

    setup_tls_parser = sub.add_parser("setup-tls", help="Set up TLS certificates for HTTPS")
    setup_tls_parser.add_argument(
        "--method",
        choices=["auto", "tailscale", "mkcert", "selfsigned"],
        default="auto",
        help="TLS method: auto (detect best), tailscale, mkcert, selfsigned (default: auto)",
    )

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_cli.py -v

Expected: ALL PASS.

Step 5: Commit

cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: full auto-detection chain in setup-tls (Tailscale → mkcert → self-signed)"

Task 5: setup-tls --status display

Files:

  • Modify: muxplex/cli.py (add --status flag and display logic)
  • Modify: muxplex/tests/test_cli.py (add status tests)

Step 1: Write failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# setup-tls --status tests
# ---------------------------------------------------------------------------


def test_setup_tls_status_shows_disabled(tmp_path, monkeypatch, capsys):
    """setup-tls --status shows 'TLS: not configured' when no certs set."""
    import muxplex.settings as settings_mod

    fake_config = tmp_path / ".config" / "muxplex"
    fake_config.mkdir(parents=True)
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_config / "settings.json")

    from muxplex.cli import setup_tls_status
    setup_tls_status()

    captured = capsys.readouterr()
    assert "not configured" in captured.out.lower() or "disabled" in captured.out.lower()


def test_setup_tls_status_shows_enabled(tmp_path, monkeypatch, capsys):
    """setup-tls --status shows cert info when TLS is configured."""
    import muxplex.settings as settings_mod

    fake_config = tmp_path / ".config" / "muxplex"
    fake_config.mkdir(parents=True)

    cert_file = fake_config / "cert.pem"
    key_file = fake_config / "key.pem"
    from muxplex.tls import generate_self_signed
    generate_self_signed(cert_path=cert_file, key_path=key_file)

    settings_file = fake_config / "settings.json"
    settings_file.write_text(json.dumps({
        "tls_cert": str(cert_file),
        "tls_key": str(key_file),
    }))
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)

    from muxplex.cli import setup_tls_status
    setup_tls_status()

    captured = capsys.readouterr()
    out = captured.out.lower()
    assert "enabled" in out or "certificate" in out
    assert "expires" in out

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_setup_tls_status_shows_disabled muxplex/tests/test_cli.py::test_setup_tls_status_shows_enabled -v

Expected: FAIL — ImportError: cannot import name 'setup_tls_status'.

Step 3: Add setup_tls_status() and --status flag

Add setup_tls_status() to muxplex/cli.py (near setup_tls()):

def setup_tls_status() -> None:
    """Show current TLS configuration status."""
    from muxplex.settings import load_settings  # noqa: PLC0415
    from muxplex.tls import get_cert_info  # noqa: PLC0415

    settings = load_settings()
    tls_cert = settings.get("tls_cert", "")
    tls_key = settings.get("tls_key", "")

    print("\n  muxplex TLS status\n")

    if not tls_cert or not tls_key:
        print("  TLS: not configured")
        print("  Run: muxplex setup-tls")
        print()
        return

    print(f"  Certificate: {tls_cert}")
    print(f"  Key:         {tls_key}")

    info = get_cert_info(tls_cert)
    if info:
        print(f"  Hostnames:   {', '.join(info['hostnames'])}")
        print(f"  Expires:     {info['expires']}")
        print(f"  Status:      enabled")
    else:
        print(f"  Status:      configured but cert not readable")

    print()

Add --status flag to the setup_tls_parser in main():

    setup_tls_parser.add_argument(
        "--status",
        action="store_true",
        help="Show current TLS configuration status",
    )

Update the dispatch in main():

    elif args.command == "setup-tls":
        if getattr(args, "status", False):
            setup_tls_status()
        else:
            setup_tls(method=args.method)

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_cli.py -v

Expected: ALL PASS.

Step 5: Commit

cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: add setup-tls --status display"

Task 6: Existing cert detection and regenerate prompt

Files:

  • Modify: muxplex/cli.py (add existing cert check at top of setup_tls())
  • Modify: muxplex/tests/test_cli.py (add regenerate prompt tests)

Step 1: Write failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# Existing cert detection tests
# ---------------------------------------------------------------------------


def test_setup_tls_prompts_when_certs_exist(tmp_path, monkeypatch, capsys):
    """setup_tls() prompts when TLS is already configured and user says no."""
    import muxplex.settings as settings_mod
    import muxplex.tls as tls_mod

    fake_config = tmp_path / ".config" / "muxplex"
    fake_config.mkdir(parents=True)

    cert_file = fake_config / "cert.pem"
    key_file = fake_config / "key.pem"
    from muxplex.tls import generate_self_signed
    generate_self_signed(cert_path=cert_file, key_path=key_file)

    settings_file = fake_config / "settings.json"
    settings_file.write_text(json.dumps({
        "tls_cert": str(cert_file),
        "tls_key": str(key_file),
    }))
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)

    # User says "n" to regenerate
    monkeypatch.setattr("builtins.input", lambda _: "n")

    # Disable detection to isolate prompt behavior
    monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
    monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: False)

    from muxplex.cli import setup_tls
    setup_tls(method="auto")

    captured = capsys.readouterr()
    assert "already configured" in captured.out.lower() or "regenerate" in captured.out.lower()


def test_setup_tls_regenerates_on_eof(tmp_path, monkeypatch, capsys):
    """setup_tls() does NOT regenerate when EOFError on prompt (non-interactive)."""
    import muxplex.settings as settings_mod
    import muxplex.tls as tls_mod

    fake_config = tmp_path / ".config" / "muxplex"
    fake_config.mkdir(parents=True)

    cert_file = fake_config / "cert.pem"
    key_file = fake_config / "key.pem"
    from muxplex.tls import generate_self_signed
    generate_self_signed(cert_path=cert_file, key_path=key_file)

    settings_file = fake_config / "settings.json"
    settings_file.write_text(json.dumps({
        "tls_cert": str(cert_file),
        "tls_key": str(key_file),
    }))
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)

    monkeypatch.setattr("builtins.input", lambda _: (_ for _ in ()).throw(EOFError))
    monkeypatch.setattr(tls_mod, "detect_tailscale", lambda: None)
    monkeypatch.setattr(tls_mod, "detect_mkcert", lambda: False)

    from muxplex.cli import setup_tls
    # Should not crash
    setup_tls(method="auto")

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_setup_tls_prompts_when_certs_exist -v

Expected: FAIL — setup_tls() doesn't check for existing certs.

Step 3: Add existing cert check to top of setup_tls()

At the top of the setup_tls() function, after defining cert_path and key_path, add:

    # Check for existing TLS configuration
    from muxplex.settings import load_settings  # noqa: PLC0415
    from muxplex.tls import get_cert_info  # noqa: PLC0415

    existing_settings = load_settings()
    existing_cert = existing_settings.get("tls_cert", "")
    existing_key = existing_settings.get("tls_key", "")
    if existing_cert and existing_key and Path(existing_cert).is_file():
        info = get_cert_info(existing_cert)
        if info:
            expires = info["expires"][:10]  # YYYY-MM-DD
            print(f"\n  TLS already configured (expires {expires}).")
            try:
                answer = input("  Regenerate? [y/N] ").strip().lower()
            except (EOFError, KeyboardInterrupt):
                answer = "n"
            if answer not in ("y", "yes"):
                print("  Keeping existing certificates.")
                print()
                return

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_cli.py -v

Expected: ALL PASS.

Step 5: Commit

cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: setup-tls prompts before regenerating existing certs"

Task 7: Full test sweep for Phase 2

Files:

  • Modify: muxplex/tests/test_tls.py (add auto-detection integration tests)
  • Modify: muxplex/tests/test_cli.py (add edge case tests)

Step 1: Add mkcert Tailscale SAN integration test to test_tls.py

Append to muxplex/tests/test_tls.py:

def test_generate_mkcert_includes_tailscale_sans(tmp_path, monkeypatch):
    """generate_mkcert() includes Tailscale hostname/IP as extra SANs when provided."""
    import subprocess
    from muxplex.tls import generate_mkcert

    cert_path = tmp_path / "cert.pem"
    key_path = tmp_path / "key.pem"

    gen_calls = []

    def fake_run(cmd, **kw):
        if "-cert-file" in cmd:
            gen_calls.append(list(cmd))
            cert_path.write_text("-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n")
            key_path.write_text("-----BEGIN RSA PRIVATE KEY-----\nFAKE\n-----END RSA PRIVATE KEY-----\n")
        return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()

    monkeypatch.setattr(subprocess, "run", fake_run)

    result = generate_mkcert(
        cert_path=cert_path,
        key_path=key_path,
        extra_hostnames=["spark-1.tail8f3c4e.ts.net", "100.64.0.1"],
    )

    assert result is not None
    # The mkcert command must include the Tailscale hostname
    assert any("spark-1.tail8f3c4e.ts.net" in str(c) for c in gen_calls)

Step 2: Run full test suite

cd muxplex && python -m pytest muxplex/tests/test_settings.py muxplex/tests/test_cli.py muxplex/tests/test_tls.py -v

Expected: ALL PASS.

Step 3: Commit

cd muxplex && git add muxplex/tests/test_tls.py muxplex/tests/test_cli.py && git commit -m "test: add Phase 2 integration and edge case tests"

Task 8: README update with full TLS documentation

Files:

  • Modify: README.md

Step 1: Update the HTTPS / TLS feature section

In README.md, update the ### HTTPS / TLS section added in Phase 1 to remove "(Phase 2)" notes:

### HTTPS / TLS

- `muxplex setup-tls` — auto-detect and set up TLS certificates
- **Tailscale** — real Let's Encrypt certs via `tailscale cert` (recommended)
- **mkcert** — locally-trusted certs, zero browser warnings
- **Self-signed** — fallback for immediate HTTPS (browser shows warning)
- Required for browser clipboard API on non-localhost

Step 2: Update the HTTPS / TLS examples section

In README.md, update the TLS examples section added in Phase 1:

### HTTPS / TLS setup

```bash
# Auto-detect best TLS method (Tailscale → mkcert → self-signed)
muxplex setup-tls

# Force a specific method
muxplex setup-tls --method tailscale
muxplex setup-tls --method mkcert
muxplex setup-tls --method selfsigned

# Check current TLS status
muxplex setup-tls --status

# Override TLS cert for this run only
muxplex serve --tls-cert /path/cert.pem --tls-key /path/key.pem

# Check TLS status in doctor
muxplex doctor

Detection priority: If Tailscale is running with HTTPS Certificates enabled, setup-tls uses tailscale cert for real Let's Encrypt certificates (universally trusted, 90-day expiry). If mkcert is installed, it generates locally-trusted certificates. Otherwise, it falls back to self-signed.

Tailscale cert renewal: Tailscale certs expire in 90 days. Run muxplex setup-tls again to renew.


**Step 3: Update CLI reference**

In the CLI reference block, update the setup-tls line:

muxplex setup-tls [--method auto] Set up TLS certs (Tailscale/mkcert/self-signed) muxplex setup-tls --status Show current TLS configuration


**Step 4: Commit**

```bash
cd muxplex && git add README.md && git commit -m "docs: update README with full TLS documentation (Tailscale, mkcert, auto-detect)"

Phase 2 Checklist

After completing all 8 tasks, verify:

cd muxplex && python -m pytest muxplex/tests/ -v

All tests must pass. The following must work:

  1. muxplex setup-tls — auto-detects Tailscale → mkcert → self-signed
  2. muxplex setup-tls --method tailscale — uses Tailscale cert
  3. muxplex setup-tls --method mkcert — uses mkcert
  4. muxplex setup-tls --method selfsigned — uses self-signed
  5. muxplex setup-tls --status — shows current TLS config
  6. Re-running muxplex setup-tls prompts to regenerate existing certs
  7. muxplex doctor — shows TLS enabled/disabled with cert expiry
  8. muxplex serve — starts HTTPS when TLS configured, HTTP when not