Files
muxplex/docs/plans/2026-04-03-tls-phase1-foundation.md
T

40 KiB
Raw Blame History

TLS Setup — Phase 1: Foundation

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

Phase 1 of 2. Complete this phase before starting Phase 2 (auto-detection + Tailscale + mkcert).

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

Goal: Add HTTPS support to muxplex so the browser Clipboard API works on non-localhost devices, starting with settings, SSL-enabled serve(), self-signed cert generation, and doctor integration.

Architecture: Two new settings keys (tls_cert, tls_key) flow through the existing serve() resolution chain (CLI flag → settings.json → default). A new muxplex/tls.py module owns all cert logic. When both paths are set and files exist, uvicorn starts with SSL. A setup-tls subcommand with self-signed fallback is the v1 entry point.

Tech Stack: Python stdlib (ssl, subprocess, datetime), cryptography library for self-signed cert generation, uvicorn built-in SSL, argparse.

Scope boundaries:

  • IN this phase: Settings, serve SSL, --tls-cert/--tls-key flags, self-signed cert generation, setup-tls skeleton with --method selfsigned, doctor TLS section, tests, README
  • DEFERRED to Phase 2: Tailscale detection, mkcert detection, auto-detection chain, --status, existing cert detection + regenerate prompt
  • OUT of scope entirely: Automatic cert renewal cron, Caddy integration, Let's Encrypt DNS-01

Task 1: Add tls_cert and tls_key to DEFAULT_SETTINGS

Files:

  • Modify: muxplex/settings.py (line 1632, the DEFAULT_SETTINGS dict)
  • Modify: muxplex/tests/test_settings.py (append new tests at bottom)

Step 1: Write failing tests

Append to muxplex/tests/test_settings.py:

# ============================================================
# TLS settings keys (task: tls-setup phase 1)
# ============================================================


def test_defaults_include_tls_cert():
    """DEFAULT_SETTINGS must have 'tls_cert' key initialised to empty string."""
    assert "tls_cert" in DEFAULT_SETTINGS, (
        "DEFAULT_SETTINGS must include 'tls_cert'"
    )
    assert DEFAULT_SETTINGS["tls_cert"] == "", (
        f"tls_cert default must be '', got: {DEFAULT_SETTINGS['tls_cert']!r}"
    )


def test_defaults_include_tls_key():
    """DEFAULT_SETTINGS must have 'tls_key' key initialised to empty string."""
    assert "tls_key" in DEFAULT_SETTINGS, (
        "DEFAULT_SETTINGS must include 'tls_key'"
    )
    assert DEFAULT_SETTINGS["tls_key"] == "", (
        f"tls_key default must be '', got: {DEFAULT_SETTINGS['tls_key']!r}"
    )


def test_load_returns_tls_keys_when_file_missing():
    """load_settings() returns tls_cert and tls_key with empty defaults when file is missing."""
    result = load_settings()
    assert result["tls_cert"] == "", (
        f"load_settings() tls_cert must default to '', got: {result['tls_cert']!r}"
    )
    assert result["tls_key"] == "", (
        f"load_settings() tls_key must default to '', got: {result['tls_key']!r}"
    )


def test_tls_keys_patchable():
    """patch_settings() must accept and persist tls_cert and tls_key."""
    result = patch_settings({"tls_cert": "/path/to/cert.pem", "tls_key": "/path/to/key.pem"})
    assert result["tls_cert"] == "/path/to/cert.pem", (
        f"patch_settings() must accept tls_cert, got: {result['tls_cert']!r}"
    )
    assert result["tls_key"] == "/path/to/key.pem", (
        f"patch_settings() must accept tls_key, got: {result['tls_key']!r}"
    )
    loaded = load_settings()
    assert loaded["tls_cert"] == "/path/to/cert.pem"
    assert loaded["tls_key"] == "/path/to/key.pem"


def test_old_settings_file_without_tls_keys_loads_correctly(redirect_settings_path):
    """Old settings.json without TLS keys loads correctly with empty defaults filled in."""
    old_settings = {"host": "0.0.0.0", "port": 8088}
    redirect_settings_path.write_text(json.dumps(old_settings))

    result = load_settings()

    assert result["host"] == "0.0.0.0"
    assert result["tls_cert"] == "", (
        f"tls_cert must default to '' for old settings files, got: {result['tls_cert']!r}"
    )
    assert result["tls_key"] == "", (
        f"tls_key must default to '' for old settings files, got: {result['tls_key']!r}"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_settings.py::test_defaults_include_tls_cert muxplex/tests/test_settings.py::test_defaults_include_tls_key muxplex/tests/test_settings.py::test_load_returns_tls_keys_when_file_missing muxplex/tests/test_settings.py::test_tls_keys_patchable muxplex/tests/test_settings.py::test_old_settings_file_without_tls_keys_loads_correctly -v

Expected: FAIL — "tls_cert" in DEFAULT_SETTINGS is False.

Step 3: Add the settings keys

In muxplex/settings.py, add these two keys to the DEFAULT_SETTINGS dict, right after the "federation_key": "" line (line 31):

    "tls_cert": "",
    "tls_key": "",

The dict should end like:

    "federation_key": "",
    "tls_cert": "",
    "tls_key": "",
}

Step 4: Run tests to verify they pass

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

Expected: ALL PASS (both new and existing tests).

Step 5: Commit

cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add tls_cert and tls_key to DEFAULT_SETTINGS"

Task 2: Update serve() to pass SSL params to uvicorn

Files:

  • Modify: muxplex/cli.py (the serve() function, lines 202234)
  • Modify: muxplex/tests/test_cli.py (append new tests)

Step 1: Write failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# TLS serve() integration tests
# ---------------------------------------------------------------------------


def test_serve_passes_ssl_params_to_uvicorn(tmp_path, monkeypatch):
    """serve() must pass ssl_certfile and ssl_keyfile to uvicorn.run() when both TLS paths are set and files exist."""
    # Create fake cert and key files
    cert_file = tmp_path / "cert.pem"
    key_file = tmp_path / "key.pem"
    cert_file.write_text("FAKE CERT")
    key_file.write_text("FAKE KEY")

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

    calls = []

    def fake_run(*args, **kwargs):
        calls.append(kwargs)

    with patch("uvicorn.run", fake_run):
        with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
            from muxplex.cli import serve
            serve()

    assert len(calls) == 1
    assert calls[0]["ssl_certfile"] == str(cert_file)
    assert calls[0]["ssl_keyfile"] == str(key_file)


def test_serve_no_ssl_when_tls_paths_empty(tmp_path, monkeypatch):
    """serve() must NOT pass ssl_certfile/ssl_keyfile when TLS paths are empty (default)."""
    settings_file = tmp_path / "settings.json"
    settings_file.write_text(json.dumps({}))
    monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)

    calls = []

    def fake_run(*args, **kwargs):
        calls.append(kwargs)

    with patch("uvicorn.run", fake_run):
        with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
            from muxplex.cli import serve
            serve()

    assert len(calls) == 1
    assert "ssl_certfile" not in calls[0]
    assert "ssl_keyfile" not in calls[0]


def test_serve_falls_back_to_http_when_cert_file_missing(tmp_path, monkeypatch, capsys):
    """serve() must warn and skip SSL when cert file in settings doesn't exist on disk."""
    settings_file = tmp_path / "settings.json"
    settings_file.write_text(json.dumps({
        "tls_cert": "/nonexistent/cert.pem",
        "tls_key": "/nonexistent/key.pem",
    }))
    monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)

    calls = []

    def fake_run(*args, **kwargs):
        calls.append(kwargs)

    with patch("uvicorn.run", fake_run):
        with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
            from muxplex.cli import serve
            serve()

    assert len(calls) == 1
    assert "ssl_certfile" not in calls[0]

    captured = capsys.readouterr()
    assert "falling back" in captured.out.lower() or "not found" in captured.out.lower()


def test_serve_prints_https_url_when_tls_active(tmp_path, monkeypatch, capsys):
    """serve() must print https:// URL when TLS is active."""
    cert_file = tmp_path / "cert.pem"
    key_file = tmp_path / "key.pem"
    cert_file.write_text("FAKE CERT")
    key_file.write_text("FAKE KEY")

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

    with patch("uvicorn.run"):
        with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
            from muxplex.cli import serve
            serve()

    captured = capsys.readouterr()
    assert "https://" in captured.out


def test_serve_prints_http_url_when_no_tls(tmp_path, monkeypatch, capsys):
    """serve() must print http:// URL when no TLS configured."""
    settings_file = tmp_path / "settings.json"
    settings_file.write_text(json.dumps({}))
    monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)

    with patch("uvicorn.run"):
        with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
            from muxplex.cli import serve
            serve()

    captured = capsys.readouterr()
    assert "http://" in captured.out
    assert "https://" not in captured.out

Step 2: Run tests to verify they fail

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

Expected: FAIL — serve() doesn't read tls_cert/tls_key from settings yet.

Step 3: Update serve() in muxplex/cli.py

Replace the serve() function (lines 202234) with:

def serve(
    host: str | None = None,
    port: int | None = None,
    auth: str | None = None,
    session_ttl: int | None = None,
    tls_cert: str | None = None,
    tls_key: str | None = None,
) -> None:
    """Start the muxplex server.

    Resolution order: CLI flag (if not None) > settings.json > hardcoded default.
    """
    import uvicorn  # noqa: PLC0415

    from muxplex.settings import load_settings  # noqa: PLC0415

    settings = load_settings()
    host = host if host is not None else settings.get("host", "127.0.0.1")
    port = port if port is not None else settings.get("port", 8088)
    auth = auth if auth is not None else settings.get("auth", "pam")
    session_ttl = (
        session_ttl if session_ttl is not None else settings.get("session_ttl", 604800)
    )
    tls_cert = tls_cert if tls_cert is not None else settings.get("tls_cert", "")
    tls_key = tls_key if tls_key is not None else settings.get("tls_key", "")

    os.environ["MUXPLEX_PORT"] = str(port)
    os.environ["MUXPLEX_AUTH"] = auth
    os.environ["MUXPLEX_SESSION_TTL"] = str(session_ttl)

    # Prevent crash-loop on restart: kill any stale process holding the port
    _kill_stale_port_holder(port)

    from muxplex.main import app  # noqa: PLC0415

    # Resolve TLS: both paths must be non-empty and exist on disk
    ssl_kwargs: dict = {}
    if tls_cert and tls_key:
        from pathlib import Path  # noqa: PLC0415

        cert_exists = Path(tls_cert).is_file()
        key_exists = Path(tls_key).is_file()
        if cert_exists and key_exists:
            ssl_kwargs["ssl_certfile"] = tls_cert
            ssl_kwargs["ssl_keyfile"] = tls_key
        else:
            missing = []
            if not cert_exists:
                missing.append(f"cert ({tls_cert})")
            if not key_exists:
                missing.append(f"key ({tls_key})")
            print(
                f"  Warning: TLS {' and '.join(missing)} not found, falling back to HTTP"
            )

    scheme = "https" if ssl_kwargs else "http"
    print(f"  muxplex → {scheme}://{host}:{port}")
    uvicorn.run(app, host=host, port=port, log_level="info", **ssl_kwargs)

Step 4: Run tests to verify they pass

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

Expected: ALL PASS (both new and existing tests).

Step 5: Commit

cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: serve() passes SSL params to uvicorn when TLS configured"

Task 3: Add --tls-cert and --tls-key CLI flags

Files:

  • Modify: muxplex/cli.py (the _add_serve_flags() function at line 668, and main() dispatch at line 813817)
  • Modify: muxplex/tests/test_cli.py (append new tests)

Step 1: Write failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# TLS CLI flags tests
# ---------------------------------------------------------------------------


def test_main_passes_tls_cert_and_key_flags():
    """main() with --tls-cert/--tls-key must forward them to serve()."""
    from muxplex.cli import main

    with patch("muxplex.cli.serve") as mock_serve:
        with patch("sys.argv", ["muxplex", "--tls-cert", "/path/cert.pem", "--tls-key", "/path/key.pem"]):
            main()
        mock_serve.assert_called_once_with(
            host=None, port=None, auth=None, session_ttl=None,
            tls_cert="/path/cert.pem", tls_key="/path/key.pem",
        )


def test_main_passes_none_for_unset_tls_flags():
    """main() with no TLS flags passes None for tls_cert/tls_key to serve()."""
    from muxplex.cli import main

    with patch("muxplex.cli.serve") as mock_serve:
        with patch("sys.argv", ["muxplex"]):
            main()
        _, kwargs = mock_serve.call_args
        assert kwargs["tls_cert"] is None
        assert kwargs["tls_key"] is None


def test_serve_subcommand_accepts_tls_flags():
    """'muxplex serve --tls-cert ... --tls-key ...' forwards values to serve()."""
    from muxplex.cli import main

    with patch("muxplex.cli.serve") as mock_serve:
        with patch("sys.argv", ["muxplex", "serve", "--tls-cert", "/c.pem", "--tls-key", "/k.pem"]):
            main()
        _, kwargs = mock_serve.call_args
        assert kwargs["tls_cert"] == "/c.pem"
        assert kwargs["tls_key"] == "/k.pem"

Step 2: Run tests to verify they fail

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

Expected: FAIL — serve() got unexpected keyword argument tls_cert (or argparse doesn't know --tls-cert).

Step 3: Add the CLI flags

In muxplex/cli.py, add these two arguments to the _add_serve_flags() function (after the --session-ttl argument, before the closing of the function):

    parser.add_argument(
        "--tls-cert",
        default=None,
        dest="tls_cert",
        help="Path to TLS certificate file (default: from settings.json)",
    )
    parser.add_argument(
        "--tls-key",
        default=None,
        dest="tls_key",
        help="Path to TLS private key file (default: from settings.json)",
    )

Then update the main() dispatch in the else block (the default serve path, around line 813817) to pass the new flags:

    else:
        _check_dependencies()
        serve(
            host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl,
            tls_cert=args.tls_cert, tls_key=args.tls_key,
        )

Also update the elif args.command == "serve" dispatch path. Currently there isn't one — it falls through to the else block. The else handles both bare muxplex and muxplex serve. Both paths need the new kwargs. Since the else block handles both cases, this one change is sufficient.

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 --tls-cert and --tls-key CLI flags to serve"

Task 4: Create muxplex/tls.py with self-signed cert generation

Files:

  • Create: muxplex/tls.py
  • Create: muxplex/tests/test_tls.py

Step 1: Write failing tests

Create muxplex/tests/test_tls.py:

"""Tests for muxplex/tls.py — TLS certificate management."""

import json
from datetime import datetime, timezone
from pathlib import Path

import pytest


def test_tls_module_importable():
    """muxplex.tls must be importable."""
    from muxplex.tls import generate_self_signed  # noqa: F401


def test_generate_self_signed_creates_cert_and_key(tmp_path):
    """generate_self_signed() creates cert.pem and key.pem at the specified paths."""
    from muxplex.tls import generate_self_signed

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

    generate_self_signed(cert_path=cert_path, key_path=key_path)

    assert cert_path.exists(), "cert.pem must be created"
    assert key_path.exists(), "key.pem must be created"


def test_generate_self_signed_cert_is_valid_pem(tmp_path):
    """Generated cert must start with -----BEGIN CERTIFICATE-----."""
    from muxplex.tls import generate_self_signed

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

    generate_self_signed(cert_path=cert_path, key_path=key_path)

    cert_content = cert_path.read_text()
    assert cert_content.startswith("-----BEGIN CERTIFICATE-----"), (
        f"cert must be PEM format, got: {cert_content[:50]!r}"
    )


def test_generate_self_signed_key_is_valid_pem(tmp_path):
    """Generated key must start with -----BEGIN."""
    from muxplex.tls import generate_self_signed

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

    generate_self_signed(cert_path=cert_path, key_path=key_path)

    key_content = key_path.read_text()
    assert key_content.startswith("-----BEGIN"), (
        f"key must be PEM format, got: {key_content[:50]!r}"
    )


def test_generate_self_signed_key_permissions(tmp_path):
    """Generated key file must have 0o600 permissions."""
    import stat
    from muxplex.tls import generate_self_signed

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

    generate_self_signed(cert_path=cert_path, key_path=key_path)

    file_mode = stat.S_IMODE(key_path.stat().st_mode)
    assert file_mode == 0o600, f"key.pem must be 0o600, got {oct(file_mode)}"


def test_generate_self_signed_returns_metadata(tmp_path):
    """generate_self_signed() returns dict with method, cert_path, key_path, hostnames, expires."""
    from muxplex.tls import generate_self_signed

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

    result = generate_self_signed(cert_path=cert_path, key_path=key_path)

    assert result["method"] == "selfsigned"
    assert result["cert_path"] == str(cert_path)
    assert result["key_path"] == str(key_path)
    assert isinstance(result["hostnames"], list)
    assert len(result["hostnames"]) > 0
    assert "expires" in result


def test_generate_self_signed_creates_parent_dirs(tmp_path):
    """generate_self_signed() creates parent directories if they don't exist."""
    from muxplex.tls import generate_self_signed

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

    generate_self_signed(cert_path=cert_path, key_path=key_path)

    assert cert_path.exists()
    assert key_path.exists()


def test_get_cert_info_returns_expiry(tmp_path):
    """get_cert_info() returns dict with expires, hostnames, method for a valid cert."""
    from muxplex.tls import generate_self_signed, get_cert_info

    cert_path = tmp_path / "cert.pem"
    key_path = tmp_path / "key.pem"
    generate_self_signed(cert_path=cert_path, key_path=key_path)

    info = get_cert_info(cert_path)

    assert "expires" in info
    assert "hostnames" in info
    assert isinstance(info["hostnames"], list)


def test_get_cert_info_returns_none_for_missing_file(tmp_path):
    """get_cert_info() returns None when cert file doesn't exist."""
    from muxplex.tls import get_cert_info

    info = get_cert_info(tmp_path / "nonexistent.pem")
    assert info is None

Step 2: Run tests to verify they fail

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

Expected: FAIL — ModuleNotFoundError: No module named 'muxplex.tls'.

Step 3: Create muxplex/tls.py

Create muxplex/tls.py:

"""muxplex/tls.py — TLS certificate management.

Handles certificate generation (self-signed, mkcert, Tailscale),
cert inspection (expiry, SANs), and auto-detection of available methods.
"""

import socket
import ssl
from datetime import datetime, timezone
from pathlib import Path


def generate_self_signed(
    cert_path: Path | str,
    key_path: Path | str,
    hostnames: list[str] | None = None,
    days_valid: int = 3650,
) -> dict:
    """Generate a self-signed TLS certificate and private key.

    Args:
        cert_path: Where to write the certificate PEM file.
        key_path: Where to write the private key PEM file.
        hostnames: Subject Alternative Names. Defaults to hostname, localhost, 127.0.0.1, ::1.
        days_valid: Certificate validity in days (default: 10 years).

    Returns:
        Dict with keys: method, cert_path, key_path, hostnames, expires.
    """
    from cryptography import x509
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import rsa
    from cryptography.x509.oid import NameOID
    import ipaddress

    cert_path = Path(cert_path)
    key_path = Path(key_path)

    if hostnames is None:
        hostname = socket.gethostname()
        hostnames = [hostname, f"{hostname}.local", "localhost"]

    # Generate RSA key
    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

    # Build certificate
    subject = issuer = x509.Name([
        x509.NameAttribute(NameOID.COMMON_NAME, hostnames[0]),
        x509.NameAttribute(NameOID.ORGANIZATION_NAME, "muxplex"),
    ])

    now = datetime.now(timezone.utc)
    expires = now.replace(year=now.year + (days_valid // 365))

    # Build SAN list — DNS names and IP addresses
    san_entries: list[x509.GeneralName] = []
    ip_strings = ["127.0.0.1", "::1"]
    for name in hostnames:
        san_entries.append(x509.DNSName(name))
    for ip_str in ip_strings:
        san_entries.append(x509.IPAddress(ipaddress.ip_address(ip_str)))

    cert = (
        x509.CertificateBuilder()
        .subject_name(subject)
        .issuer_name(issuer)
        .public_key(private_key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(now)
        .not_valid_after(expires)
        .add_extension(x509.SubjectAlternativeName(san_entries), critical=False)
        .sign(private_key, hashes.SHA256())
    )

    # Write files
    cert_path.parent.mkdir(parents=True, exist_ok=True)
    key_path.parent.mkdir(parents=True, exist_ok=True)

    cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
    key_path.write_bytes(
        private_key.private_bytes(
            serialization.Encoding.PEM,
            serialization.PrivateFormat.TraditionalOpenSSL,
            serialization.NoEncryption(),
        )
    )
    key_path.chmod(0o600)

    return {
        "method": "selfsigned",
        "cert_path": str(cert_path),
        "key_path": str(key_path),
        "hostnames": hostnames,
        "expires": expires.isoformat(),
    }


def get_cert_info(cert_path: Path | str) -> dict | None:
    """Inspect a PEM certificate and return metadata.

    Returns:
        Dict with keys: expires, hostnames, not_before, serial.
        Returns None if the file does not exist or is unreadable.
    """
    from cryptography import x509

    cert_path = Path(cert_path)
    if not cert_path.is_file():
        return None

    try:
        cert_data = cert_path.read_bytes()
        cert = x509.load_pem_x509_certificate(cert_data)
    except Exception:
        return None

    # Extract SANs
    hostnames: list[str] = []
    try:
        san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
        hostnames.extend(san_ext.value.get_values_for_type(x509.DNSName))
        hostnames.extend(
            str(ip) for ip in san_ext.value.get_values_for_type(x509.IPAddress)
        )
    except x509.ExtensionNotFound:
        pass

    return {
        "expires": cert.not_valid_after_utc.isoformat(),
        "not_before": cert.not_valid_before_utc.isoformat(),
        "hostnames": hostnames,
        "serial": str(cert.serial_number),
    }

Step 4: Run tests to verify they pass

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

Expected: ALL PASS. If cryptography is not installed, run: cd muxplex && uv pip install cryptography

Step 5: Commit

cd muxplex && git add muxplex/tls.py muxplex/tests/test_tls.py && git commit -m "feat: add tls.py with self-signed cert generation and cert inspection"

Task 5: Add setup-tls subcommand with self-signed path

Files:

  • Modify: muxplex/cli.py (add subcommand registration and dispatch)
  • Modify: muxplex/tests/test_cli.py (append new tests)

Step 1: Write failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# setup-tls subcommand tests
# ---------------------------------------------------------------------------


def test_setup_tls_subcommand_registered():
    """setup-tls must be a valid subcommand in main() argparse."""
    import io
    from muxplex.cli import main

    buf = io.StringIO()
    with patch("sys.argv", ["muxplex", "--help"]):
        try:
            with patch("sys.stdout", buf):
                main()
        except SystemExit:
            pass

    help_text = buf.getvalue().lower()
    assert "setup-tls" in help_text


def test_main_dispatches_to_setup_tls(monkeypatch):
    """main() with 'setup-tls' subcommand must invoke setup_tls()."""
    import muxplex.cli as cli_mod

    calls = []
    monkeypatch.setattr(cli_mod, "setup_tls", lambda method: calls.append(method))

    with patch("sys.argv", ["muxplex", "setup-tls"]):
        cli_mod.main()

    assert len(calls) == 1


def test_setup_tls_selfsigned_creates_certs(tmp_path, monkeypatch, capsys):
    """setup_tls(method='selfsigned') generates certs and updates settings."""
    import muxplex.settings as settings_mod

    # Redirect settings and config dir to tmp_path
    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

    # Monkeypatch the cert/key default paths to use tmp_path
    monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", fake_config / "settings.json")

    setup_tls(method="selfsigned")

    # Settings must have been updated with cert paths
    settings = settings_mod.load_settings()
    assert settings["tls_cert"] != "", "tls_cert must be set after setup-tls"
    assert settings["tls_key"] != "", "tls_key must be set after setup-tls"

    # Cert files must exist
    assert Path(settings["tls_cert"]).exists(), "cert file must exist"
    assert Path(settings["tls_key"]).exists(), "key file must exist"

    # Output must mention method and restart hint
    captured = capsys.readouterr()
    assert "self-signed" in captured.out.lower() or "selfsigned" in captured.out.lower()
    assert "restart" in captured.out.lower()

Step 2: Run tests to verify they fail

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

Expected: FAIL — setup-tls not in help, setup_tls not in cli_mod.

Step 3: Add the subcommand

In muxplex/cli.py, add the setup_tls() function before the main() function:

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

    In Phase 1, only 'selfsigned' and 'auto' (which falls through to selfsigned) are supported.
    Phase 2 adds Tailscale and mkcert detection.
    """
    from muxplex.settings import SETTINGS_PATH, patch_settings  # noqa: PLC0415
    from muxplex.tls import generate_self_signed  # noqa: PLC0415

    config_dir = SETTINGS_PATH.parent
    cert_path = config_dir / "cert.pem"
    key_path = config_dir / "key.pem"

    if method in ("auto", "selfsigned"):
        result = generate_self_signed(cert_path=cert_path, key_path=key_path)
        patch_settings({"tls_cert": result["cert_path"], "tls_key": result["key_path"]})

        print(f"\n  TLS configured (self-signed)")
        print(f"  Certificate: {result['cert_path']}")
        print(f"  Key:         {result['key_path']}")
        print(f"  Hostnames:   {', '.join(result['hostnames'])}")
        print(f"  Expires:     {result['expires']}")
        print()
        print("  Warning: Browsers will show a security warning.")
        print("  For trusted certs, install mkcert or use Tailscale.")
        print()
        print("  Restart service to apply: muxplex service restart")
        print()
    else:
        print(f"  Unknown TLS method: {method}", file=sys.stderr)
        print("  Valid methods: auto, selfsigned", file=sys.stderr)
        sys.exit(1)

In the main() function, register the subparser (add after the config_parser block, before args = parser.parse_args()):

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

In the main() dispatch section, add a new elif before the else block:

    elif args.command == "setup-tls":
        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 subcommand with self-signed cert generation"

Task 6: Doctor TLS status display

Files:

  • Modify: muxplex/cli.py (the doctor() function, insert TLS section)
  • Modify: muxplex/tests/test_cli.py (append new tests)

Step 1: Write failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# doctor TLS status tests
# ---------------------------------------------------------------------------


def test_doctor_shows_tls_disabled(tmp_path, monkeypatch, capsys):
    """doctor() must show 'TLS: disabled' when no TLS configured."""
    import muxplex.settings as settings_mod

    settings_file = tmp_path / "settings.json"
    settings_file.write_text(json.dumps({}))
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)

    from muxplex.cli import doctor
    doctor()

    out = capsys.readouterr().out.lower()
    assert "tls" in out
    assert "disabled" in out


def test_doctor_shows_tls_enabled(tmp_path, monkeypatch, capsys):
    """doctor() must show 'TLS: enabled' with expiry when TLS is configured and certs exist."""
    import muxplex.settings as settings_mod

    cert_file = tmp_path / "cert.pem"
    key_file = tmp_path / "key.pem"

    # Generate real certs
    from muxplex.tls import generate_self_signed
    generate_self_signed(cert_path=cert_file, key_path=key_file)

    settings_file = tmp_path / "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 doctor
    doctor()

    out = capsys.readouterr().out.lower()
    assert "tls" in out
    assert "enabled" in out


def test_doctor_shows_tls_clipboard_warning(tmp_path, monkeypatch, capsys):
    """doctor() must mention clipboard requires HTTPS when TLS is disabled."""
    import muxplex.settings as settings_mod

    settings_file = tmp_path / "settings.json"
    settings_file.write_text(json.dumps({}))
    monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)

    from muxplex.cli import doctor
    doctor()

    out = capsys.readouterr().out.lower()
    assert "clipboard" in out or "https" in out

Step 2: Run tests to verify they fail

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

Expected: FAIL — doctor output doesn't mention TLS.

Step 3: Add TLS section to doctor()

In muxplex/cli.py, in the doctor() function, add the following TLS block right after the "Serve config" section (after line 327 f" (auth={cfg['auth']}, ttl={cfg['session_ttl']}s)") and before the "Auth status" section (line 330 pw_path = get_password_path()):

    # TLS status
    tls_cert = cfg.get("tls_cert", "")
    tls_key = cfg.get("tls_key", "")
    if tls_cert and tls_key:
        from muxplex.tls import get_cert_info  # noqa: PLC0415

        cert_info = get_cert_info(tls_cert)
        if cert_info:
            from datetime import datetime, timezone  # noqa: PLC0415

            expires = datetime.fromisoformat(cert_info["expires"])
            now = datetime.now(timezone.utc)
            if expires < now:
                days_ago = (now - expires).days
                print(
                    f"  {warn_mark} TLS: WARNING — cert expired {days_ago} days ago."
                    " Run muxplex setup-tls to renew"
                )
            else:
                print(
                    f"  {ok_mark} TLS: enabled (cert expires {expires.strftime('%Y-%m-%d')})"
                )
        else:
            print(
                f"  {warn_mark} TLS: configured but cert not readable ({tls_cert})"
            )
    else:
        print(
            f"  {warn_mark} TLS: disabled (clipboard requires HTTPS on non-localhost)"
        )

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: doctor shows TLS status with expiry and clipboard warning"

Task 7: Full test sweep and edge cases

Files:

  • Modify: muxplex/tests/test_tls.py (add edge case tests)
  • Modify: muxplex/tests/test_cli.py (add edge case tests)

Step 1: Add edge case tests to test_tls.py

Append to muxplex/tests/test_tls.py:

def test_generate_self_signed_with_custom_hostnames(tmp_path):
    """generate_self_signed() accepts custom hostnames list."""
    from muxplex.tls import generate_self_signed

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

    result = generate_self_signed(
        cert_path=cert_path,
        key_path=key_path,
        hostnames=["mybox.local", "mybox.tailnet.ts.net"],
    )

    assert "mybox.local" in result["hostnames"]
    assert "mybox.tailnet.ts.net" in result["hostnames"]


def test_get_cert_info_hostnames_include_ip(tmp_path):
    """get_cert_info() includes IP SANs from the generated cert."""
    from muxplex.tls import generate_self_signed, get_cert_info

    cert_path = tmp_path / "cert.pem"
    key_path = tmp_path / "key.pem"
    generate_self_signed(cert_path=cert_path, key_path=key_path)

    info = get_cert_info(cert_path)

    assert info is not None
    assert "127.0.0.1" in info["hostnames"]


def test_get_cert_info_returns_none_for_corrupt_file(tmp_path):
    """get_cert_info() returns None for a file that isn't valid PEM."""
    from muxplex.tls import get_cert_info

    bad_cert = tmp_path / "bad.pem"
    bad_cert.write_text("THIS IS NOT A CERTIFICATE")

    info = get_cert_info(bad_cert)
    assert info is None

Step 2: Add edge case test to test_cli.py

Append to muxplex/tests/test_cli.py:

def test_serve_warns_when_only_cert_set(tmp_path, monkeypatch, capsys):
    """serve() must warn when only tls_cert is set but tls_key is empty."""
    cert_file = tmp_path / "cert.pem"
    cert_file.write_text("FAKE CERT")

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

    calls = []

    def fake_run(*args, **kwargs):
        calls.append(kwargs)

    with patch("uvicorn.run", fake_run):
        with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
            from muxplex.cli import serve
            serve()

    assert len(calls) == 1
    assert "ssl_certfile" not in calls[0], "SSL must not be enabled with only cert set"

Step 3: 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 4: Commit

cd muxplex && git add muxplex/tests/test_tls.py muxplex/tests/test_cli.py && git commit -m "test: add TLS edge case tests for cert generation and serve fallback"

Task 8: README TLS section

Files:

  • Modify: README.md

Step 1: Add TLS entries to Features section

In README.md, add a new subsection under ### Developer Tools (after line 56 - \muxplex config` — CLI settings management`):


### HTTPS / TLS

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

Step 2: Add TLS row to CLI Reference

In README.md, in the CLI Reference block (around line 127), add after the muxplex reset-secret line:

muxplex setup-tls [--method auto]   Set up TLS certs for HTTPS

Step 3: Add TLS rows to Configuration table

In README.md, in the Configuration table (around line 179), add after the multi_device_enabled row:

| `tls_cert` | `""` | Path to TLS certificate file (empty = HTTP) |
| `tls_key` | `""` | Path to TLS private key file (empty = HTTP) |

Step 4: Add TLS Examples section

In README.md, after the Examples section (around line 175), add:


### HTTPS / TLS setup

```bash
# Auto-detect best TLS method and configure
muxplex setup-tls

# Force self-signed certificate
muxplex setup-tls --method selfsigned

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

# Check TLS status
muxplex doctor

**Step 5: Commit**

```bash
cd muxplex && git add README.md && git commit -m "docs: add TLS setup section to README"

Phase 1 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 --method selfsigned — generates certs, updates settings
  2. muxplex serve — starts with HTTPS when certs are configured
  3. muxplex serve --tls-cert /path --tls-key /path — CLI flag override
  4. muxplex doctor — shows TLS enabled/disabled status
  5. muxplex config get tls_cert — shows configured cert path

Phase 2 (auto-detection, Tailscale, mkcert) builds on this foundation.