diff --git a/muxplex/cli.py b/muxplex/cli.py index bc5733c..4072063 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -681,6 +681,54 @@ def config_reset(key: str | None = None) -> None: 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: """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", ) + 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_sub = config_parser.add_subparsers(dest="config_command") config_sub.add_parser("list", help="Show all settings (default)") @@ -810,6 +868,8 @@ def main() -> None: else: # Default: list (no subcommand or explicit "list") config_list() + elif args.command == "setup-tls": + setup_tls(method=args.method) elif args.command == "service": from muxplex.service import ( # noqa: PLC0415 service_install, diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index e6c55a6..ee5f3cc 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -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(): """'muxplex serve --tls-cert ... --tls-key ...' must forward both paths to serve().""" from muxplex.cli import main