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

1246 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TLS Setup — Phase 1: Foundation
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
>
> **Phase 1 of 2.** Complete this phase before starting Phase 2 (auto-detection + Tailscale + mkcert).
>
> **Design doc:** `docs/plans/2026-04-03-tls-setup-design.md`
**Goal:** Add HTTPS support to muxplex so the browser Clipboard API works on non-localhost devices, starting with settings, SSL-enabled `serve()`, self-signed cert generation, and doctor integration.
**Architecture:** Two new settings keys (`tls_cert`, `tls_key`) flow through the existing `serve()` resolution chain (CLI flag → settings.json → default). A new `muxplex/tls.py` module owns all cert logic. When both paths are set and files exist, uvicorn starts with SSL. A `setup-tls` subcommand with self-signed fallback is the v1 entry point.
**Tech Stack:** Python stdlib (`ssl`, `subprocess`, `datetime`), `cryptography` library for self-signed cert generation, uvicorn built-in SSL, argparse.
**Scope boundaries:**
- **IN this phase:** Settings, serve SSL, `--tls-cert`/`--tls-key` flags, self-signed cert generation, `setup-tls` skeleton with `--method selfsigned`, doctor TLS section, tests, README
- **DEFERRED to Phase 2:** Tailscale detection, mkcert detection, auto-detection chain, `--status`, existing cert detection + regenerate prompt
- **OUT of scope entirely:** Automatic cert renewal cron, Caddy integration, Let's Encrypt DNS-01
---
### Task 1: Add `tls_cert` and `tls_key` to DEFAULT_SETTINGS
**Files:**
- Modify: `muxplex/settings.py` (line 1632, the `DEFAULT_SETTINGS` dict)
- Modify: `muxplex/tests/test_settings.py` (append new tests at bottom)
**Step 1: Write failing tests**
Append to `muxplex/tests/test_settings.py`:
```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 202234)
- 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 202234) 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 813817)
- 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 813817) 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.