feat: add setup-tls subcommand for TLS certificate configuration

- Adds setup_tls() function to generate self-signed certificates in config dir
- Registers 'setup-tls' subcommand with --method argument (auto/selfsigned, default: auto)
- Automatically updates settings with cert/key paths after generation
- Outputs summary with self-signed warning and restart hint
- Adds 3 tests: subcommand registration, dispatch flow, and cert generation
- All 83 tests pass

Generated with Amplifier
This commit is contained in:
Brian Krabach
2026-04-03 22:00:02 -07:00
parent f6316f029d
commit 41a3adbf53
2 changed files with 145 additions and 0 deletions
+60
View File
@@ -681,6 +681,54 @@ def config_reset(key: str | None = None) -> None:
print(f" All settings reset to defaults ({SETTINGS_PATH})") print(f" All settings reset to defaults ({SETTINGS_PATH})")
def setup_tls(method: str = "auto") -> None:
"""Generate TLS certificates and update settings.
For method 'auto' or 'selfsigned': generates a self-signed certificate
and private key in the muxplex config dir, then updates settings.json with
the paths.
For unknown method: prints an error to stderr and exits with code 1.
"""
from muxplex.settings import SETTINGS_PATH, patch_settings # noqa: PLC0415
from muxplex.tls import generate_self_signed # noqa: PLC0415
if method not in ("auto", "selfsigned"):
print(
f"Error: unknown TLS method '{method}'. Valid: auto, selfsigned",
file=sys.stderr,
)
sys.exit(1)
config_dir = SETTINGS_PATH.parent
cert_path = config_dir / "muxplex.crt"
key_path = config_dir / "muxplex.key"
info = generate_self_signed(cert_path, key_path)
patch_settings({"tls_cert": str(cert_path), "tls_key": str(key_path)})
hostnames_str = ", ".join(info["hostnames"])
expiry_str = (
info["expires"].strftime("%Y-%m-%d")
if hasattr(info["expires"], "strftime")
else str(info["expires"])
)
print("TLS setup complete")
print(" Method: self-signed (selfsigned)")
print(f" Cert: {info['cert_path']}")
print(f" Key: {info['key_path']}")
print(f" Hostnames: {hostnames_str}")
print(f" Expires: {expiry_str}")
print()
print(" Note: Browsers will show a security warning for self-signed certificates.")
print(" You can accept the warning or add the cert to your system trust store.")
print()
print(" Restart muxplex to apply TLS settings:")
print(" muxplex service restart")
def _add_serve_flags(parser: argparse.ArgumentParser) -> None: def _add_serve_flags(parser: argparse.ArgumentParser) -> None:
"""Add --host, --port, --auth, --session-ttl, --tls-cert, --tls-key flags to a parser. """Add --host, --port, --auth, --session-ttl, --tls-cert, --tls-key flags to a parser.
@@ -774,6 +822,16 @@ def main() -> None:
help="Force reinstall even if already up to date", help="Force reinstall even if already up to date",
) )
setup_tls_parser = sub.add_parser(
"setup-tls", help="Generate TLS certificate and configure HTTPS"
)
setup_tls_parser.add_argument(
"--method",
choices=["auto", "selfsigned"],
default="auto",
help="Certificate generation method (default: auto)",
)
config_parser = sub.add_parser("config", help="View and manage settings") config_parser = sub.add_parser("config", help="View and manage settings")
config_sub = config_parser.add_subparsers(dest="config_command") config_sub = config_parser.add_subparsers(dest="config_command")
config_sub.add_parser("list", help="Show all settings (default)") config_sub.add_parser("list", help="Show all settings (default)")
@@ -810,6 +868,8 @@ def main() -> None:
else: else:
# Default: list (no subcommand or explicit "list") # Default: list (no subcommand or explicit "list")
config_list() config_list()
elif args.command == "setup-tls":
setup_tls(method=args.method)
elif args.command == "service": elif args.command == "service":
from muxplex.service import ( # noqa: PLC0415 from muxplex.service import ( # noqa: PLC0415
service_install, service_install,
+85
View File
@@ -1451,6 +1451,91 @@ def test_main_passes_none_for_unset_tls_flags():
) )
# ---------------------------------------------------------------------------
# task-5: setup-tls subcommand tests
# ---------------------------------------------------------------------------
def test_setup_tls_subcommand_registered():
"""'setup-tls' must appear in muxplex --help output."""
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()
assert "setup-tls" in help_text, (
f"'setup-tls' must appear in --help output, got:\n{help_text}"
)
def test_main_dispatches_to_setup_tls(monkeypatch):
"""main() with 'setup-tls' subcommand must invoke setup_tls(method='auto')."""
import muxplex.cli as cli_mod
calls = []
monkeypatch.setattr(
cli_mod, "setup_tls", lambda method="auto": calls.append(method)
)
with patch("sys.argv", ["muxplex", "setup-tls"]):
cli_mod.main()
assert len(calls) == 1, "setup_tls() must be called once for 'setup-tls' subcommand"
assert calls[0] == "auto", (
f"setup_tls must be called with method='auto', got {calls[0]!r}"
)
def test_setup_tls_selfsigned_creates_certs(tmp_path, monkeypatch, capsys):
"""setup_tls(method='selfsigned') generates cert and key in config dir, updates settings,
prints summary mentioning 'self-signed'/'selfsigned' and 'restart'."""
import muxplex.settings as settings_mod
from muxplex.cli import setup_tls
# Redirect SETTINGS_PATH to tmp_path
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
setup_tls(method="selfsigned")
# Cert and key files must exist in the config dir (SETTINGS_PATH.parent = tmp_path)
cert_files = list(tmp_path.glob("*.crt")) + list(tmp_path.glob("*.pem"))
key_files = list(tmp_path.glob("*.key"))
assert cert_files, (
f"Cert file must exist in {tmp_path}, found: {list(tmp_path.iterdir())}"
)
assert key_files, (
f"Key file must exist in {tmp_path}, found: {list(tmp_path.iterdir())}"
)
# Settings must be updated with non-empty tls_cert and tls_key
settings = settings_mod.load_settings()
assert settings.get("tls_cert"), (
"tls_cert must be non-empty in settings after setup_tls"
)
assert settings.get("tls_key"), (
"tls_key must be non-empty in settings after setup_tls"
)
# Output must mention self-signed and restart
captured = capsys.readouterr()
out_lower = captured.out.lower()
assert "self-signed" in out_lower or "selfsigned" in out_lower, (
f"Output must mention 'self-signed' or 'selfsigned', got: {captured.out!r}"
)
assert "restart" in out_lower, (
f"Output must mention 'restart', got: {captured.out!r}"
)
def test_serve_subcommand_accepts_tls_flags(): def test_serve_subcommand_accepts_tls_flags():
"""'muxplex serve --tls-cert ... --tls-key ...' must forward both paths to serve().""" """'muxplex serve --tls-cert ... --tls-key ...' must forward both paths to serve()."""
from muxplex.cli import main from muxplex.cli import main