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