1231 lines
39 KiB
Markdown
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 |