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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user