From 7b10c61327d82312db74d17bcd1e619781bdece6 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Fri, 3 Apr 2026 21:19:17 -0700 Subject: [PATCH] docs: TLS setup implementation plans (Phase 1 foundation + Phase 2 auto-detection) --- .../plans/2026-04-03-tls-phase1-foundation.md | 1246 +++++++++++++++++ .../plans/2026-04-03-tls-phase2-autodetect.md | 1231 ++++++++++++++++ 2 files changed, 2477 insertions(+) create mode 100644 docs/plans/2026-04-03-tls-phase1-foundation.md create mode 100644 docs/plans/2026-04-03-tls-phase2-autodetect.md diff --git a/docs/plans/2026-04-03-tls-phase1-foundation.md b/docs/plans/2026-04-03-tls-phase1-foundation.md new file mode 100644 index 0000000..a412642 --- /dev/null +++ b/docs/plans/2026-04-03-tls-phase1-foundation.md @@ -0,0 +1,1246 @@ +# 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 16–32, 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`: + +```python +# ============================================================ +# 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** + +```bash +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): + +```python + "tls_cert": "", + "tls_key": "", +``` + +The dict should end like: +```python + "federation_key": "", + "tls_cert": "", + "tls_key": "", +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_settings.py -v +``` + +Expected: ALL PASS (both new and existing tests). + +**Step 5: Commit** + +```bash +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 202–234) +- Modify: `muxplex/tests/test_cli.py` (append new tests) + +**Step 1: Write failing tests** + +Append to `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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 202–234) with: + +```python +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** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_cli.py -v +``` + +Expected: ALL PASS (both new and existing tests). + +**Step 5: Commit** + +```bash +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 813–817) +- Modify: `muxplex/tests/test_cli.py` (append new tests) + +**Step 1: Write failing tests** + +Append to `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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): + +```python + 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 813–817) to pass the new flags: + +```python + 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** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_cli.py -v +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +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`: + +```python +"""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** + +```bash +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`: + +```python +"""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** + +```bash +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** + +```bash +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`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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: + +```python +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()`): + +```python + 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: + +```python + elif args.command == "setup-tls": + setup_tls(method=args.method) +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_cli.py -v +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +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`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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()`): + +```python + # 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** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_cli.py -v +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +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`: + +```python +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`: + +```python +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** + +```bash +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** + +```bash +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`): + +```markdown + +### 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: + +```markdown + +### 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: + +```bash +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. \ No newline at end of file diff --git a/docs/plans/2026-04-03-tls-phase2-autodetect.md b/docs/plans/2026-04-03-tls-phase2-autodetect.md new file mode 100644 index 0000000..0b4f2c9 --- /dev/null +++ b/docs/plans/2026-04-03-tls-phase2-autodetect.md @@ -0,0 +1,1231 @@ +# 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`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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`: + +```python +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** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_tls.py -v +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +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`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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`: + +```python +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** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_tls.py -v +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +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`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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`: + +```python +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** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_tls.py -v +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +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`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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: + +```python +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: + +```python + 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** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_cli.py -v +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +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`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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()`): + +```python +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()`: + +```python + setup_tls_parser.add_argument( + "--status", + action="store_true", + help="Show current TLS configuration status", + ) +``` + +Update the dispatch in `main()`: + +```python + 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** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_cli.py -v +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +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`: + +```python +# --------------------------------------------------------------------------- +# 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** + +```bash +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: + +```python + # 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** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_cli.py -v +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +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`: + +```python +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** + +```bash +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** + +```bash +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: + +```markdown +### 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: + +```markdown +### 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: + +```bash +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 \ No newline at end of file