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

1231 lines
39 KiB
Markdown

# 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