1246 lines
40 KiB
Markdown
1246 lines
40 KiB
Markdown
# TLS Setup — Phase 1: Foundation
|
||
|
||
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
|
||
>
|
||
> **Phase 1 of 2.** Complete this phase before starting Phase 2 (auto-detection + Tailscale + mkcert).
|
||
>
|
||
> **Design doc:** `docs/plans/2026-04-03-tls-setup-design.md`
|
||
|
||
**Goal:** Add HTTPS support to muxplex so the browser Clipboard API works on non-localhost devices, starting with settings, SSL-enabled `serve()`, self-signed cert generation, and doctor integration.
|
||
|
||
**Architecture:** Two new settings keys (`tls_cert`, `tls_key`) flow through the existing `serve()` resolution chain (CLI flag → settings.json → default). A new `muxplex/tls.py` module owns all cert logic. When both paths are set and files exist, uvicorn starts with SSL. A `setup-tls` subcommand with self-signed fallback is the v1 entry point.
|
||
|
||
**Tech Stack:** Python stdlib (`ssl`, `subprocess`, `datetime`), `cryptography` library for self-signed cert generation, uvicorn built-in SSL, argparse.
|
||
|
||
**Scope boundaries:**
|
||
- **IN this phase:** Settings, serve SSL, `--tls-cert`/`--tls-key` flags, self-signed cert generation, `setup-tls` skeleton with `--method selfsigned`, doctor TLS section, tests, README
|
||
- **DEFERRED to Phase 2:** Tailscale detection, mkcert detection, auto-detection chain, `--status`, existing cert detection + regenerate prompt
|
||
- **OUT of scope entirely:** Automatic cert renewal cron, Caddy integration, Let's Encrypt DNS-01
|
||
|
||
---
|
||
|
||
### Task 1: Add `tls_cert` and `tls_key` to DEFAULT_SETTINGS
|
||
|
||
**Files:**
|
||
- Modify: `muxplex/settings.py` (line 16–32, the `DEFAULT_SETTINGS` dict)
|
||
- Modify: `muxplex/tests/test_settings.py` (append new tests at bottom)
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Append to `muxplex/tests/test_settings.py`:
|
||
|
||
```python
|
||
# ============================================================
|
||
# TLS settings keys (task: tls-setup phase 1)
|
||
# ============================================================
|
||
|
||
|
||
def test_defaults_include_tls_cert():
|
||
"""DEFAULT_SETTINGS must have 'tls_cert' key initialised to empty string."""
|
||
assert "tls_cert" in DEFAULT_SETTINGS, (
|
||
"DEFAULT_SETTINGS must include 'tls_cert'"
|
||
)
|
||
assert DEFAULT_SETTINGS["tls_cert"] == "", (
|
||
f"tls_cert default must be '', got: {DEFAULT_SETTINGS['tls_cert']!r}"
|
||
)
|
||
|
||
|
||
def test_defaults_include_tls_key():
|
||
"""DEFAULT_SETTINGS must have 'tls_key' key initialised to empty string."""
|
||
assert "tls_key" in DEFAULT_SETTINGS, (
|
||
"DEFAULT_SETTINGS must include 'tls_key'"
|
||
)
|
||
assert DEFAULT_SETTINGS["tls_key"] == "", (
|
||
f"tls_key default must be '', got: {DEFAULT_SETTINGS['tls_key']!r}"
|
||
)
|
||
|
||
|
||
def test_load_returns_tls_keys_when_file_missing():
|
||
"""load_settings() returns tls_cert and tls_key with empty defaults when file is missing."""
|
||
result = load_settings()
|
||
assert result["tls_cert"] == "", (
|
||
f"load_settings() tls_cert must default to '', got: {result['tls_cert']!r}"
|
||
)
|
||
assert result["tls_key"] == "", (
|
||
f"load_settings() tls_key must default to '', got: {result['tls_key']!r}"
|
||
)
|
||
|
||
|
||
def test_tls_keys_patchable():
|
||
"""patch_settings() must accept and persist tls_cert and tls_key."""
|
||
result = patch_settings({"tls_cert": "/path/to/cert.pem", "tls_key": "/path/to/key.pem"})
|
||
assert result["tls_cert"] == "/path/to/cert.pem", (
|
||
f"patch_settings() must accept tls_cert, got: {result['tls_cert']!r}"
|
||
)
|
||
assert result["tls_key"] == "/path/to/key.pem", (
|
||
f"patch_settings() must accept tls_key, got: {result['tls_key']!r}"
|
||
)
|
||
loaded = load_settings()
|
||
assert loaded["tls_cert"] == "/path/to/cert.pem"
|
||
assert loaded["tls_key"] == "/path/to/key.pem"
|
||
|
||
|
||
def test_old_settings_file_without_tls_keys_loads_correctly(redirect_settings_path):
|
||
"""Old settings.json without TLS keys loads correctly with empty defaults filled in."""
|
||
old_settings = {"host": "0.0.0.0", "port": 8088}
|
||
redirect_settings_path.write_text(json.dumps(old_settings))
|
||
|
||
result = load_settings()
|
||
|
||
assert result["host"] == "0.0.0.0"
|
||
assert result["tls_cert"] == "", (
|
||
f"tls_cert must default to '' for old settings files, got: {result['tls_cert']!r}"
|
||
)
|
||
assert result["tls_key"] == "", (
|
||
f"tls_key must default to '' for old settings files, got: {result['tls_key']!r}"
|
||
)
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_settings.py::test_defaults_include_tls_cert muxplex/tests/test_settings.py::test_defaults_include_tls_key muxplex/tests/test_settings.py::test_load_returns_tls_keys_when_file_missing muxplex/tests/test_settings.py::test_tls_keys_patchable muxplex/tests/test_settings.py::test_old_settings_file_without_tls_keys_loads_correctly -v
|
||
```
|
||
|
||
Expected: FAIL — `"tls_cert" in DEFAULT_SETTINGS` is False.
|
||
|
||
**Step 3: Add the settings keys**
|
||
|
||
In `muxplex/settings.py`, add these two keys to the `DEFAULT_SETTINGS` dict, right after the `"federation_key": ""` line (line 31):
|
||
|
||
```python
|
||
"tls_cert": "",
|
||
"tls_key": "",
|
||
```
|
||
|
||
The dict should end like:
|
||
```python
|
||
"federation_key": "",
|
||
"tls_cert": "",
|
||
"tls_key": "",
|
||
}
|
||
```
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_settings.py -v
|
||
```
|
||
|
||
Expected: ALL PASS (both new and existing tests).
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add tls_cert and tls_key to DEFAULT_SETTINGS"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Update `serve()` to pass SSL params to uvicorn
|
||
|
||
**Files:**
|
||
- Modify: `muxplex/cli.py` (the `serve()` function, lines 202–234)
|
||
- Modify: `muxplex/tests/test_cli.py` (append new tests)
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Append to `muxplex/tests/test_cli.py`:
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# TLS serve() integration tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_serve_passes_ssl_params_to_uvicorn(tmp_path, monkeypatch):
|
||
"""serve() must pass ssl_certfile and ssl_keyfile to uvicorn.run() when both TLS paths are set and files exist."""
|
||
# Create fake cert and key files
|
||
cert_file = tmp_path / "cert.pem"
|
||
key_file = tmp_path / "key.pem"
|
||
cert_file.write_text("FAKE CERT")
|
||
key_file.write_text("FAKE KEY")
|
||
|
||
settings_file = tmp_path / "settings.json"
|
||
settings_file.write_text(json.dumps({
|
||
"tls_cert": str(cert_file),
|
||
"tls_key": str(key_file),
|
||
}))
|
||
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
|
||
|
||
calls = []
|
||
|
||
def fake_run(*args, **kwargs):
|
||
calls.append(kwargs)
|
||
|
||
with patch("uvicorn.run", fake_run):
|
||
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
|
||
from muxplex.cli import serve
|
||
serve()
|
||
|
||
assert len(calls) == 1
|
||
assert calls[0]["ssl_certfile"] == str(cert_file)
|
||
assert calls[0]["ssl_keyfile"] == str(key_file)
|
||
|
||
|
||
def test_serve_no_ssl_when_tls_paths_empty(tmp_path, monkeypatch):
|
||
"""serve() must NOT pass ssl_certfile/ssl_keyfile when TLS paths are empty (default)."""
|
||
settings_file = tmp_path / "settings.json"
|
||
settings_file.write_text(json.dumps({}))
|
||
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
|
||
|
||
calls = []
|
||
|
||
def fake_run(*args, **kwargs):
|
||
calls.append(kwargs)
|
||
|
||
with patch("uvicorn.run", fake_run):
|
||
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
|
||
from muxplex.cli import serve
|
||
serve()
|
||
|
||
assert len(calls) == 1
|
||
assert "ssl_certfile" not in calls[0]
|
||
assert "ssl_keyfile" not in calls[0]
|
||
|
||
|
||
def test_serve_falls_back_to_http_when_cert_file_missing(tmp_path, monkeypatch, capsys):
|
||
"""serve() must warn and skip SSL when cert file in settings doesn't exist on disk."""
|
||
settings_file = tmp_path / "settings.json"
|
||
settings_file.write_text(json.dumps({
|
||
"tls_cert": "/nonexistent/cert.pem",
|
||
"tls_key": "/nonexistent/key.pem",
|
||
}))
|
||
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
|
||
|
||
calls = []
|
||
|
||
def fake_run(*args, **kwargs):
|
||
calls.append(kwargs)
|
||
|
||
with patch("uvicorn.run", fake_run):
|
||
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
|
||
from muxplex.cli import serve
|
||
serve()
|
||
|
||
assert len(calls) == 1
|
||
assert "ssl_certfile" not in calls[0]
|
||
|
||
captured = capsys.readouterr()
|
||
assert "falling back" in captured.out.lower() or "not found" in captured.out.lower()
|
||
|
||
|
||
def test_serve_prints_https_url_when_tls_active(tmp_path, monkeypatch, capsys):
|
||
"""serve() must print https:// URL when TLS is active."""
|
||
cert_file = tmp_path / "cert.pem"
|
||
key_file = tmp_path / "key.pem"
|
||
cert_file.write_text("FAKE CERT")
|
||
key_file.write_text("FAKE KEY")
|
||
|
||
settings_file = tmp_path / "settings.json"
|
||
settings_file.write_text(json.dumps({
|
||
"tls_cert": str(cert_file),
|
||
"tls_key": str(key_file),
|
||
}))
|
||
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
|
||
|
||
with patch("uvicorn.run"):
|
||
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
|
||
from muxplex.cli import serve
|
||
serve()
|
||
|
||
captured = capsys.readouterr()
|
||
assert "https://" in captured.out
|
||
|
||
|
||
def test_serve_prints_http_url_when_no_tls(tmp_path, monkeypatch, capsys):
|
||
"""serve() must print http:// URL when no TLS configured."""
|
||
settings_file = tmp_path / "settings.json"
|
||
settings_file.write_text(json.dumps({}))
|
||
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
|
||
|
||
with patch("uvicorn.run"):
|
||
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
|
||
from muxplex.cli import serve
|
||
serve()
|
||
|
||
captured = capsys.readouterr()
|
||
assert "http://" in captured.out
|
||
assert "https://" not in captured.out
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_serve_passes_ssl_params_to_uvicorn muxplex/tests/test_cli.py::test_serve_no_ssl_when_tls_paths_empty -v
|
||
```
|
||
|
||
Expected: FAIL — `serve()` doesn't read `tls_cert`/`tls_key` from settings yet.
|
||
|
||
**Step 3: Update `serve()` in `muxplex/cli.py`**
|
||
|
||
Replace the `serve()` function (lines 202–234) with:
|
||
|
||
```python
|
||
def serve(
|
||
host: str | None = None,
|
||
port: int | None = None,
|
||
auth: str | None = None,
|
||
session_ttl: int | None = None,
|
||
tls_cert: str | None = None,
|
||
tls_key: str | None = None,
|
||
) -> None:
|
||
"""Start the muxplex server.
|
||
|
||
Resolution order: CLI flag (if not None) > settings.json > hardcoded default.
|
||
"""
|
||
import uvicorn # noqa: PLC0415
|
||
|
||
from muxplex.settings import load_settings # noqa: PLC0415
|
||
|
||
settings = load_settings()
|
||
host = host if host is not None else settings.get("host", "127.0.0.1")
|
||
port = port if port is not None else settings.get("port", 8088)
|
||
auth = auth if auth is not None else settings.get("auth", "pam")
|
||
session_ttl = (
|
||
session_ttl if session_ttl is not None else settings.get("session_ttl", 604800)
|
||
)
|
||
tls_cert = tls_cert if tls_cert is not None else settings.get("tls_cert", "")
|
||
tls_key = tls_key if tls_key is not None else settings.get("tls_key", "")
|
||
|
||
os.environ["MUXPLEX_PORT"] = str(port)
|
||
os.environ["MUXPLEX_AUTH"] = auth
|
||
os.environ["MUXPLEX_SESSION_TTL"] = str(session_ttl)
|
||
|
||
# Prevent crash-loop on restart: kill any stale process holding the port
|
||
_kill_stale_port_holder(port)
|
||
|
||
from muxplex.main import app # noqa: PLC0415
|
||
|
||
# Resolve TLS: both paths must be non-empty and exist on disk
|
||
ssl_kwargs: dict = {}
|
||
if tls_cert and tls_key:
|
||
from pathlib import Path # noqa: PLC0415
|
||
|
||
cert_exists = Path(tls_cert).is_file()
|
||
key_exists = Path(tls_key).is_file()
|
||
if cert_exists and key_exists:
|
||
ssl_kwargs["ssl_certfile"] = tls_cert
|
||
ssl_kwargs["ssl_keyfile"] = tls_key
|
||
else:
|
||
missing = []
|
||
if not cert_exists:
|
||
missing.append(f"cert ({tls_cert})")
|
||
if not key_exists:
|
||
missing.append(f"key ({tls_key})")
|
||
print(
|
||
f" Warning: TLS {' and '.join(missing)} not found, falling back to HTTP"
|
||
)
|
||
|
||
scheme = "https" if ssl_kwargs else "http"
|
||
print(f" muxplex → {scheme}://{host}:{port}")
|
||
uvicorn.run(app, host=host, port=port, log_level="info", **ssl_kwargs)
|
||
```
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_cli.py -v
|
||
```
|
||
|
||
Expected: ALL PASS (both new and existing tests).
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: serve() passes SSL params to uvicorn when TLS configured"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Add `--tls-cert` and `--tls-key` CLI flags
|
||
|
||
**Files:**
|
||
- Modify: `muxplex/cli.py` (the `_add_serve_flags()` function at line 668, and `main()` dispatch at line 813–817)
|
||
- Modify: `muxplex/tests/test_cli.py` (append new tests)
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Append to `muxplex/tests/test_cli.py`:
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# TLS CLI flags tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_main_passes_tls_cert_and_key_flags():
|
||
"""main() with --tls-cert/--tls-key must forward them to serve()."""
|
||
from muxplex.cli import main
|
||
|
||
with patch("muxplex.cli.serve") as mock_serve:
|
||
with patch("sys.argv", ["muxplex", "--tls-cert", "/path/cert.pem", "--tls-key", "/path/key.pem"]):
|
||
main()
|
||
mock_serve.assert_called_once_with(
|
||
host=None, port=None, auth=None, session_ttl=None,
|
||
tls_cert="/path/cert.pem", tls_key="/path/key.pem",
|
||
)
|
||
|
||
|
||
def test_main_passes_none_for_unset_tls_flags():
|
||
"""main() with no TLS flags passes None for tls_cert/tls_key to serve()."""
|
||
from muxplex.cli import main
|
||
|
||
with patch("muxplex.cli.serve") as mock_serve:
|
||
with patch("sys.argv", ["muxplex"]):
|
||
main()
|
||
_, kwargs = mock_serve.call_args
|
||
assert kwargs["tls_cert"] is None
|
||
assert kwargs["tls_key"] is None
|
||
|
||
|
||
def test_serve_subcommand_accepts_tls_flags():
|
||
"""'muxplex serve --tls-cert ... --tls-key ...' forwards values to serve()."""
|
||
from muxplex.cli import main
|
||
|
||
with patch("muxplex.cli.serve") as mock_serve:
|
||
with patch("sys.argv", ["muxplex", "serve", "--tls-cert", "/c.pem", "--tls-key", "/k.pem"]):
|
||
main()
|
||
_, kwargs = mock_serve.call_args
|
||
assert kwargs["tls_cert"] == "/c.pem"
|
||
assert kwargs["tls_key"] == "/k.pem"
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_main_passes_tls_cert_and_key_flags muxplex/tests/test_cli.py::test_main_passes_none_for_unset_tls_flags -v
|
||
```
|
||
|
||
Expected: FAIL — `serve()` got unexpected keyword argument `tls_cert` (or argparse doesn't know `--tls-cert`).
|
||
|
||
**Step 3: Add the CLI flags**
|
||
|
||
In `muxplex/cli.py`, add these two arguments to the `_add_serve_flags()` function (after the `--session-ttl` argument, before the closing of the function):
|
||
|
||
```python
|
||
parser.add_argument(
|
||
"--tls-cert",
|
||
default=None,
|
||
dest="tls_cert",
|
||
help="Path to TLS certificate file (default: from settings.json)",
|
||
)
|
||
parser.add_argument(
|
||
"--tls-key",
|
||
default=None,
|
||
dest="tls_key",
|
||
help="Path to TLS private key file (default: from settings.json)",
|
||
)
|
||
```
|
||
|
||
Then update the `main()` dispatch in the `else` block (the default serve path, around line 813–817) to pass the new flags:
|
||
|
||
```python
|
||
else:
|
||
_check_dependencies()
|
||
serve(
|
||
host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl,
|
||
tls_cert=args.tls_cert, tls_key=args.tls_key,
|
||
)
|
||
```
|
||
|
||
Also update the `elif args.command == "serve"` dispatch path. Currently there isn't one — it falls through to the `else` block. The `else` handles both bare `muxplex` and `muxplex serve`. Both paths need the new kwargs. Since the `else` block handles both cases, this one change is sufficient.
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_cli.py -v
|
||
```
|
||
|
||
Expected: ALL PASS.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: add --tls-cert and --tls-key CLI flags to serve"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Create `muxplex/tls.py` with self-signed cert generation
|
||
|
||
**Files:**
|
||
- Create: `muxplex/tls.py`
|
||
- Create: `muxplex/tests/test_tls.py`
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Create `muxplex/tests/test_tls.py`:
|
||
|
||
```python
|
||
"""Tests for muxplex/tls.py — TLS certificate management."""
|
||
|
||
import json
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
|
||
def test_tls_module_importable():
|
||
"""muxplex.tls must be importable."""
|
||
from muxplex.tls import generate_self_signed # noqa: F401
|
||
|
||
|
||
def test_generate_self_signed_creates_cert_and_key(tmp_path):
|
||
"""generate_self_signed() creates cert.pem and key.pem at the specified paths."""
|
||
from muxplex.tls import generate_self_signed
|
||
|
||
cert_path = tmp_path / "cert.pem"
|
||
key_path = tmp_path / "key.pem"
|
||
|
||
generate_self_signed(cert_path=cert_path, key_path=key_path)
|
||
|
||
assert cert_path.exists(), "cert.pem must be created"
|
||
assert key_path.exists(), "key.pem must be created"
|
||
|
||
|
||
def test_generate_self_signed_cert_is_valid_pem(tmp_path):
|
||
"""Generated cert must start with -----BEGIN CERTIFICATE-----."""
|
||
from muxplex.tls import generate_self_signed
|
||
|
||
cert_path = tmp_path / "cert.pem"
|
||
key_path = tmp_path / "key.pem"
|
||
|
||
generate_self_signed(cert_path=cert_path, key_path=key_path)
|
||
|
||
cert_content = cert_path.read_text()
|
||
assert cert_content.startswith("-----BEGIN CERTIFICATE-----"), (
|
||
f"cert must be PEM format, got: {cert_content[:50]!r}"
|
||
)
|
||
|
||
|
||
def test_generate_self_signed_key_is_valid_pem(tmp_path):
|
||
"""Generated key must start with -----BEGIN."""
|
||
from muxplex.tls import generate_self_signed
|
||
|
||
cert_path = tmp_path / "cert.pem"
|
||
key_path = tmp_path / "key.pem"
|
||
|
||
generate_self_signed(cert_path=cert_path, key_path=key_path)
|
||
|
||
key_content = key_path.read_text()
|
||
assert key_content.startswith("-----BEGIN"), (
|
||
f"key must be PEM format, got: {key_content[:50]!r}"
|
||
)
|
||
|
||
|
||
def test_generate_self_signed_key_permissions(tmp_path):
|
||
"""Generated key file must have 0o600 permissions."""
|
||
import stat
|
||
from muxplex.tls import generate_self_signed
|
||
|
||
cert_path = tmp_path / "cert.pem"
|
||
key_path = tmp_path / "key.pem"
|
||
|
||
generate_self_signed(cert_path=cert_path, key_path=key_path)
|
||
|
||
file_mode = stat.S_IMODE(key_path.stat().st_mode)
|
||
assert file_mode == 0o600, f"key.pem must be 0o600, got {oct(file_mode)}"
|
||
|
||
|
||
def test_generate_self_signed_returns_metadata(tmp_path):
|
||
"""generate_self_signed() returns dict with method, cert_path, key_path, hostnames, expires."""
|
||
from muxplex.tls import generate_self_signed
|
||
|
||
cert_path = tmp_path / "cert.pem"
|
||
key_path = tmp_path / "key.pem"
|
||
|
||
result = generate_self_signed(cert_path=cert_path, key_path=key_path)
|
||
|
||
assert result["method"] == "selfsigned"
|
||
assert result["cert_path"] == str(cert_path)
|
||
assert result["key_path"] == str(key_path)
|
||
assert isinstance(result["hostnames"], list)
|
||
assert len(result["hostnames"]) > 0
|
||
assert "expires" in result
|
||
|
||
|
||
def test_generate_self_signed_creates_parent_dirs(tmp_path):
|
||
"""generate_self_signed() creates parent directories if they don't exist."""
|
||
from muxplex.tls import generate_self_signed
|
||
|
||
cert_path = tmp_path / "a" / "b" / "cert.pem"
|
||
key_path = tmp_path / "a" / "b" / "key.pem"
|
||
|
||
generate_self_signed(cert_path=cert_path, key_path=key_path)
|
||
|
||
assert cert_path.exists()
|
||
assert key_path.exists()
|
||
|
||
|
||
def test_get_cert_info_returns_expiry(tmp_path):
|
||
"""get_cert_info() returns dict with expires, hostnames, method for a valid cert."""
|
||
from muxplex.tls import generate_self_signed, get_cert_info
|
||
|
||
cert_path = tmp_path / "cert.pem"
|
||
key_path = tmp_path / "key.pem"
|
||
generate_self_signed(cert_path=cert_path, key_path=key_path)
|
||
|
||
info = get_cert_info(cert_path)
|
||
|
||
assert "expires" in info
|
||
assert "hostnames" in info
|
||
assert isinstance(info["hostnames"], list)
|
||
|
||
|
||
def test_get_cert_info_returns_none_for_missing_file(tmp_path):
|
||
"""get_cert_info() returns None when cert file doesn't exist."""
|
||
from muxplex.tls import get_cert_info
|
||
|
||
info = get_cert_info(tmp_path / "nonexistent.pem")
|
||
assert info is None
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_tls.py -v
|
||
```
|
||
|
||
Expected: FAIL — `ModuleNotFoundError: No module named 'muxplex.tls'`.
|
||
|
||
**Step 3: Create `muxplex/tls.py`**
|
||
|
||
Create `muxplex/tls.py`:
|
||
|
||
```python
|
||
"""muxplex/tls.py — TLS certificate management.
|
||
|
||
Handles certificate generation (self-signed, mkcert, Tailscale),
|
||
cert inspection (expiry, SANs), and auto-detection of available methods.
|
||
"""
|
||
|
||
import socket
|
||
import ssl
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
|
||
def generate_self_signed(
|
||
cert_path: Path | str,
|
||
key_path: Path | str,
|
||
hostnames: list[str] | None = None,
|
||
days_valid: int = 3650,
|
||
) -> dict:
|
||
"""Generate a self-signed TLS certificate and private key.
|
||
|
||
Args:
|
||
cert_path: Where to write the certificate PEM file.
|
||
key_path: Where to write the private key PEM file.
|
||
hostnames: Subject Alternative Names. Defaults to hostname, localhost, 127.0.0.1, ::1.
|
||
days_valid: Certificate validity in days (default: 10 years).
|
||
|
||
Returns:
|
||
Dict with keys: method, cert_path, key_path, hostnames, expires.
|
||
"""
|
||
from cryptography import x509
|
||
from cryptography.hazmat.primitives import hashes, serialization
|
||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||
from cryptography.x509.oid import NameOID
|
||
import ipaddress
|
||
|
||
cert_path = Path(cert_path)
|
||
key_path = Path(key_path)
|
||
|
||
if hostnames is None:
|
||
hostname = socket.gethostname()
|
||
hostnames = [hostname, f"{hostname}.local", "localhost"]
|
||
|
||
# Generate RSA key
|
||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||
|
||
# Build certificate
|
||
subject = issuer = x509.Name([
|
||
x509.NameAttribute(NameOID.COMMON_NAME, hostnames[0]),
|
||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "muxplex"),
|
||
])
|
||
|
||
now = datetime.now(timezone.utc)
|
||
expires = now.replace(year=now.year + (days_valid // 365))
|
||
|
||
# Build SAN list — DNS names and IP addresses
|
||
san_entries: list[x509.GeneralName] = []
|
||
ip_strings = ["127.0.0.1", "::1"]
|
||
for name in hostnames:
|
||
san_entries.append(x509.DNSName(name))
|
||
for ip_str in ip_strings:
|
||
san_entries.append(x509.IPAddress(ipaddress.ip_address(ip_str)))
|
||
|
||
cert = (
|
||
x509.CertificateBuilder()
|
||
.subject_name(subject)
|
||
.issuer_name(issuer)
|
||
.public_key(private_key.public_key())
|
||
.serial_number(x509.random_serial_number())
|
||
.not_valid_before(now)
|
||
.not_valid_after(expires)
|
||
.add_extension(x509.SubjectAlternativeName(san_entries), critical=False)
|
||
.sign(private_key, hashes.SHA256())
|
||
)
|
||
|
||
# Write files
|
||
cert_path.parent.mkdir(parents=True, exist_ok=True)
|
||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||
key_path.write_bytes(
|
||
private_key.private_bytes(
|
||
serialization.Encoding.PEM,
|
||
serialization.PrivateFormat.TraditionalOpenSSL,
|
||
serialization.NoEncryption(),
|
||
)
|
||
)
|
||
key_path.chmod(0o600)
|
||
|
||
return {
|
||
"method": "selfsigned",
|
||
"cert_path": str(cert_path),
|
||
"key_path": str(key_path),
|
||
"hostnames": hostnames,
|
||
"expires": expires.isoformat(),
|
||
}
|
||
|
||
|
||
def get_cert_info(cert_path: Path | str) -> dict | None:
|
||
"""Inspect a PEM certificate and return metadata.
|
||
|
||
Returns:
|
||
Dict with keys: expires, hostnames, not_before, serial.
|
||
Returns None if the file does not exist or is unreadable.
|
||
"""
|
||
from cryptography import x509
|
||
|
||
cert_path = Path(cert_path)
|
||
if not cert_path.is_file():
|
||
return None
|
||
|
||
try:
|
||
cert_data = cert_path.read_bytes()
|
||
cert = x509.load_pem_x509_certificate(cert_data)
|
||
except Exception:
|
||
return None
|
||
|
||
# Extract SANs
|
||
hostnames: list[str] = []
|
||
try:
|
||
san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||
hostnames.extend(san_ext.value.get_values_for_type(x509.DNSName))
|
||
hostnames.extend(
|
||
str(ip) for ip in san_ext.value.get_values_for_type(x509.IPAddress)
|
||
)
|
||
except x509.ExtensionNotFound:
|
||
pass
|
||
|
||
return {
|
||
"expires": cert.not_valid_after_utc.isoformat(),
|
||
"not_before": cert.not_valid_before_utc.isoformat(),
|
||
"hostnames": hostnames,
|
||
"serial": str(cert.serial_number),
|
||
}
|
||
```
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_tls.py -v
|
||
```
|
||
|
||
Expected: ALL PASS. If `cryptography` is not installed, run: `cd muxplex && uv pip install cryptography`
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
cd muxplex && git add muxplex/tls.py muxplex/tests/test_tls.py && git commit -m "feat: add tls.py with self-signed cert generation and cert inspection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Add `setup-tls` subcommand with self-signed path
|
||
|
||
**Files:**
|
||
- Modify: `muxplex/cli.py` (add subcommand registration and dispatch)
|
||
- Modify: `muxplex/tests/test_cli.py` (append new tests)
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Append to `muxplex/tests/test_cli.py`:
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# setup-tls subcommand tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_setup_tls_subcommand_registered():
|
||
"""setup-tls must be a valid subcommand in main() argparse."""
|
||
import io
|
||
from muxplex.cli import main
|
||
|
||
buf = io.StringIO()
|
||
with patch("sys.argv", ["muxplex", "--help"]):
|
||
try:
|
||
with patch("sys.stdout", buf):
|
||
main()
|
||
except SystemExit:
|
||
pass
|
||
|
||
help_text = buf.getvalue().lower()
|
||
assert "setup-tls" in help_text
|
||
|
||
|
||
def test_main_dispatches_to_setup_tls(monkeypatch):
|
||
"""main() with 'setup-tls' subcommand must invoke setup_tls()."""
|
||
import muxplex.cli as cli_mod
|
||
|
||
calls = []
|
||
monkeypatch.setattr(cli_mod, "setup_tls", lambda method: calls.append(method))
|
||
|
||
with patch("sys.argv", ["muxplex", "setup-tls"]):
|
||
cli_mod.main()
|
||
|
||
assert len(calls) == 1
|
||
|
||
|
||
def test_setup_tls_selfsigned_creates_certs(tmp_path, monkeypatch, capsys):
|
||
"""setup_tls(method='selfsigned') generates certs and updates settings."""
|
||
import muxplex.settings as settings_mod
|
||
|
||
# Redirect settings and config dir to tmp_path
|
||
fake_config = tmp_path / ".config" / "muxplex"
|
||
fake_config.mkdir(parents=True)
|
||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_config / "settings.json")
|
||
|
||
from muxplex.cli import setup_tls
|
||
|
||
# Monkeypatch the cert/key default paths to use tmp_path
|
||
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", fake_config / "settings.json")
|
||
|
||
setup_tls(method="selfsigned")
|
||
|
||
# Settings must have been updated with cert paths
|
||
settings = settings_mod.load_settings()
|
||
assert settings["tls_cert"] != "", "tls_cert must be set after setup-tls"
|
||
assert settings["tls_key"] != "", "tls_key must be set after setup-tls"
|
||
|
||
# Cert files must exist
|
||
assert Path(settings["tls_cert"]).exists(), "cert file must exist"
|
||
assert Path(settings["tls_key"]).exists(), "key file must exist"
|
||
|
||
# Output must mention method and restart hint
|
||
captured = capsys.readouterr()
|
||
assert "self-signed" in captured.out.lower() or "selfsigned" in captured.out.lower()
|
||
assert "restart" in captured.out.lower()
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_setup_tls_subcommand_registered muxplex/tests/test_cli.py::test_main_dispatches_to_setup_tls -v
|
||
```
|
||
|
||
Expected: FAIL — `setup-tls` not in help, `setup_tls` not in cli_mod.
|
||
|
||
**Step 3: Add the subcommand**
|
||
|
||
In `muxplex/cli.py`, add the `setup_tls()` function before the `main()` function:
|
||
|
||
```python
|
||
def setup_tls(method: str = "auto") -> None:
|
||
"""Set up TLS certificates for HTTPS.
|
||
|
||
In Phase 1, only 'selfsigned' and 'auto' (which falls through to selfsigned) are supported.
|
||
Phase 2 adds Tailscale and mkcert detection.
|
||
"""
|
||
from muxplex.settings import SETTINGS_PATH, patch_settings # noqa: PLC0415
|
||
from muxplex.tls import generate_self_signed # noqa: PLC0415
|
||
|
||
config_dir = SETTINGS_PATH.parent
|
||
cert_path = config_dir / "cert.pem"
|
||
key_path = config_dir / "key.pem"
|
||
|
||
if method in ("auto", "selfsigned"):
|
||
result = generate_self_signed(cert_path=cert_path, key_path=key_path)
|
||
patch_settings({"tls_cert": result["cert_path"], "tls_key": result["key_path"]})
|
||
|
||
print(f"\n TLS configured (self-signed)")
|
||
print(f" Certificate: {result['cert_path']}")
|
||
print(f" Key: {result['key_path']}")
|
||
print(f" Hostnames: {', '.join(result['hostnames'])}")
|
||
print(f" Expires: {result['expires']}")
|
||
print()
|
||
print(" Warning: Browsers will show a security warning.")
|
||
print(" For trusted certs, install mkcert or use Tailscale.")
|
||
print()
|
||
print(" Restart service to apply: muxplex service restart")
|
||
print()
|
||
else:
|
||
print(f" Unknown TLS method: {method}", file=sys.stderr)
|
||
print(" Valid methods: auto, selfsigned", file=sys.stderr)
|
||
sys.exit(1)
|
||
```
|
||
|
||
In the `main()` function, register the subparser (add after the `config_parser` block, before `args = parser.parse_args()`):
|
||
|
||
```python
|
||
setup_tls_parser = sub.add_parser("setup-tls", help="Set up TLS certificates for HTTPS")
|
||
setup_tls_parser.add_argument(
|
||
"--method",
|
||
choices=["auto", "selfsigned"],
|
||
default="auto",
|
||
help="TLS method (default: auto — detects best available)",
|
||
)
|
||
```
|
||
|
||
In the `main()` dispatch section, add a new `elif` before the `else` block:
|
||
|
||
```python
|
||
elif args.command == "setup-tls":
|
||
setup_tls(method=args.method)
|
||
```
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_cli.py -v
|
||
```
|
||
|
||
Expected: ALL PASS.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: add setup-tls subcommand with self-signed cert generation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Doctor TLS status display
|
||
|
||
**Files:**
|
||
- Modify: `muxplex/cli.py` (the `doctor()` function, insert TLS section)
|
||
- Modify: `muxplex/tests/test_cli.py` (append new tests)
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Append to `muxplex/tests/test_cli.py`:
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# doctor TLS status tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_doctor_shows_tls_disabled(tmp_path, monkeypatch, capsys):
|
||
"""doctor() must show 'TLS: disabled' when no TLS configured."""
|
||
import muxplex.settings as settings_mod
|
||
|
||
settings_file = tmp_path / "settings.json"
|
||
settings_file.write_text(json.dumps({}))
|
||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
|
||
|
||
from muxplex.cli import doctor
|
||
doctor()
|
||
|
||
out = capsys.readouterr().out.lower()
|
||
assert "tls" in out
|
||
assert "disabled" in out
|
||
|
||
|
||
def test_doctor_shows_tls_enabled(tmp_path, monkeypatch, capsys):
|
||
"""doctor() must show 'TLS: enabled' with expiry when TLS is configured and certs exist."""
|
||
import muxplex.settings as settings_mod
|
||
|
||
cert_file = tmp_path / "cert.pem"
|
||
key_file = tmp_path / "key.pem"
|
||
|
||
# Generate real certs
|
||
from muxplex.tls import generate_self_signed
|
||
generate_self_signed(cert_path=cert_file, key_path=key_file)
|
||
|
||
settings_file = tmp_path / "settings.json"
|
||
settings_file.write_text(json.dumps({
|
||
"tls_cert": str(cert_file),
|
||
"tls_key": str(key_file),
|
||
}))
|
||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
|
||
|
||
from muxplex.cli import doctor
|
||
doctor()
|
||
|
||
out = capsys.readouterr().out.lower()
|
||
assert "tls" in out
|
||
assert "enabled" in out
|
||
|
||
|
||
def test_doctor_shows_tls_clipboard_warning(tmp_path, monkeypatch, capsys):
|
||
"""doctor() must mention clipboard requires HTTPS when TLS is disabled."""
|
||
import muxplex.settings as settings_mod
|
||
|
||
settings_file = tmp_path / "settings.json"
|
||
settings_file.write_text(json.dumps({}))
|
||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
|
||
|
||
from muxplex.cli import doctor
|
||
doctor()
|
||
|
||
out = capsys.readouterr().out.lower()
|
||
assert "clipboard" in out or "https" in out
|
||
```
|
||
|
||
**Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_doctor_shows_tls_disabled muxplex/tests/test_cli.py::test_doctor_shows_tls_enabled -v
|
||
```
|
||
|
||
Expected: FAIL — doctor output doesn't mention TLS.
|
||
|
||
**Step 3: Add TLS section to `doctor()`**
|
||
|
||
In `muxplex/cli.py`, in the `doctor()` function, add the following TLS block right after the "Serve config" section (after line 327 `f" (auth={cfg['auth']}, ttl={cfg['session_ttl']}s)"`) and before the "Auth status" section (line 330 `pw_path = get_password_path()`):
|
||
|
||
```python
|
||
# TLS status
|
||
tls_cert = cfg.get("tls_cert", "")
|
||
tls_key = cfg.get("tls_key", "")
|
||
if tls_cert and tls_key:
|
||
from muxplex.tls import get_cert_info # noqa: PLC0415
|
||
|
||
cert_info = get_cert_info(tls_cert)
|
||
if cert_info:
|
||
from datetime import datetime, timezone # noqa: PLC0415
|
||
|
||
expires = datetime.fromisoformat(cert_info["expires"])
|
||
now = datetime.now(timezone.utc)
|
||
if expires < now:
|
||
days_ago = (now - expires).days
|
||
print(
|
||
f" {warn_mark} TLS: WARNING — cert expired {days_ago} days ago."
|
||
" Run muxplex setup-tls to renew"
|
||
)
|
||
else:
|
||
print(
|
||
f" {ok_mark} TLS: enabled (cert expires {expires.strftime('%Y-%m-%d')})"
|
||
)
|
||
else:
|
||
print(
|
||
f" {warn_mark} TLS: configured but cert not readable ({tls_cert})"
|
||
)
|
||
else:
|
||
print(
|
||
f" {warn_mark} TLS: disabled (clipboard requires HTTPS on non-localhost)"
|
||
)
|
||
```
|
||
|
||
**Step 4: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_cli.py -v
|
||
```
|
||
|
||
Expected: ALL PASS.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: doctor shows TLS status with expiry and clipboard warning"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Full test sweep and edge cases
|
||
|
||
**Files:**
|
||
- Modify: `muxplex/tests/test_tls.py` (add edge case tests)
|
||
- Modify: `muxplex/tests/test_cli.py` (add edge case tests)
|
||
|
||
**Step 1: Add edge case tests to `test_tls.py`**
|
||
|
||
Append to `muxplex/tests/test_tls.py`:
|
||
|
||
```python
|
||
def test_generate_self_signed_with_custom_hostnames(tmp_path):
|
||
"""generate_self_signed() accepts custom hostnames list."""
|
||
from muxplex.tls import generate_self_signed
|
||
|
||
cert_path = tmp_path / "cert.pem"
|
||
key_path = tmp_path / "key.pem"
|
||
|
||
result = generate_self_signed(
|
||
cert_path=cert_path,
|
||
key_path=key_path,
|
||
hostnames=["mybox.local", "mybox.tailnet.ts.net"],
|
||
)
|
||
|
||
assert "mybox.local" in result["hostnames"]
|
||
assert "mybox.tailnet.ts.net" in result["hostnames"]
|
||
|
||
|
||
def test_get_cert_info_hostnames_include_ip(tmp_path):
|
||
"""get_cert_info() includes IP SANs from the generated cert."""
|
||
from muxplex.tls import generate_self_signed, get_cert_info
|
||
|
||
cert_path = tmp_path / "cert.pem"
|
||
key_path = tmp_path / "key.pem"
|
||
generate_self_signed(cert_path=cert_path, key_path=key_path)
|
||
|
||
info = get_cert_info(cert_path)
|
||
|
||
assert info is not None
|
||
assert "127.0.0.1" in info["hostnames"]
|
||
|
||
|
||
def test_get_cert_info_returns_none_for_corrupt_file(tmp_path):
|
||
"""get_cert_info() returns None for a file that isn't valid PEM."""
|
||
from muxplex.tls import get_cert_info
|
||
|
||
bad_cert = tmp_path / "bad.pem"
|
||
bad_cert.write_text("THIS IS NOT A CERTIFICATE")
|
||
|
||
info = get_cert_info(bad_cert)
|
||
assert info is None
|
||
```
|
||
|
||
**Step 2: Add edge case test to `test_cli.py`**
|
||
|
||
Append to `muxplex/tests/test_cli.py`:
|
||
|
||
```python
|
||
def test_serve_warns_when_only_cert_set(tmp_path, monkeypatch, capsys):
|
||
"""serve() must warn when only tls_cert is set but tls_key is empty."""
|
||
cert_file = tmp_path / "cert.pem"
|
||
cert_file.write_text("FAKE CERT")
|
||
|
||
settings_file = tmp_path / "settings.json"
|
||
settings_file.write_text(json.dumps({
|
||
"tls_cert": str(cert_file),
|
||
"tls_key": "",
|
||
}))
|
||
monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file)
|
||
|
||
calls = []
|
||
|
||
def fake_run(*args, **kwargs):
|
||
calls.append(kwargs)
|
||
|
||
with patch("uvicorn.run", fake_run):
|
||
with patch.dict("sys.modules", {"muxplex.main": MagicMock()}):
|
||
from muxplex.cli import serve
|
||
serve()
|
||
|
||
assert len(calls) == 1
|
||
assert "ssl_certfile" not in calls[0], "SSL must not be enabled with only cert set"
|
||
```
|
||
|
||
**Step 3: Run full test suite**
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/test_settings.py muxplex/tests/test_cli.py muxplex/tests/test_tls.py -v
|
||
```
|
||
|
||
Expected: ALL PASS.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
cd muxplex && git add muxplex/tests/test_tls.py muxplex/tests/test_cli.py && git commit -m "test: add TLS edge case tests for cert generation and serve fallback"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: README TLS section
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
|
||
**Step 1: Add TLS entries to Features section**
|
||
|
||
In `README.md`, add a new subsection under `### Developer Tools` (after line 56 `- \`muxplex config\` — CLI settings management`):
|
||
|
||
```markdown
|
||
|
||
### HTTPS / TLS
|
||
|
||
- `muxplex setup-tls` — auto-detect and set up TLS certificates
|
||
- **Tailscale** — real Let's Encrypt certs via `tailscale cert` (Phase 2)
|
||
- **mkcert** — locally-trusted certs (Phase 2)
|
||
- **Self-signed** — fallback for immediate HTTPS (browser shows warning)
|
||
- Required for browser clipboard API on non-localhost
|
||
```
|
||
|
||
**Step 2: Add TLS row to CLI Reference**
|
||
|
||
In `README.md`, in the CLI Reference block (around line 127), add after the `muxplex reset-secret` line:
|
||
|
||
```
|
||
muxplex setup-tls [--method auto] Set up TLS certs for HTTPS
|
||
```
|
||
|
||
**Step 3: Add TLS rows to Configuration table**
|
||
|
||
In `README.md`, in the Configuration table (around line 179), add after the `multi_device_enabled` row:
|
||
|
||
```
|
||
| `tls_cert` | `""` | Path to TLS certificate file (empty = HTTP) |
|
||
| `tls_key` | `""` | Path to TLS private key file (empty = HTTP) |
|
||
```
|
||
|
||
**Step 4: Add TLS Examples section**
|
||
|
||
In `README.md`, after the Examples section (around line 175), add:
|
||
|
||
```markdown
|
||
|
||
### HTTPS / TLS setup
|
||
|
||
```bash
|
||
# Auto-detect best TLS method and configure
|
||
muxplex setup-tls
|
||
|
||
# Force self-signed certificate
|
||
muxplex setup-tls --method selfsigned
|
||
|
||
# Override TLS cert for this run only
|
||
muxplex serve --tls-cert /path/cert.pem --tls-key /path/key.pem
|
||
|
||
# Check TLS status
|
||
muxplex doctor
|
||
```
|
||
```
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
cd muxplex && git add README.md && git commit -m "docs: add TLS setup section to README"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 1 Checklist
|
||
|
||
After completing all 8 tasks, verify:
|
||
|
||
```bash
|
||
cd muxplex && python -m pytest muxplex/tests/ -v
|
||
```
|
||
|
||
All tests must pass. The following must work:
|
||
|
||
1. `muxplex setup-tls --method selfsigned` — generates certs, updates settings
|
||
2. `muxplex serve` — starts with HTTPS when certs are configured
|
||
3. `muxplex serve --tls-cert /path --tls-key /path` — CLI flag override
|
||
4. `muxplex doctor` — shows TLS enabled/disabled status
|
||
5. `muxplex config get tls_cert` — shows configured cert path
|
||
|
||
Phase 2 (auto-detection, Tailscale, mkcert) builds on this foundation. |