diff --git a/muxplex/cli.py b/muxplex/cli.py index d97861c..26ff24c 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -5,12 +5,25 @@ import os import sys from pathlib import Path -from muxplex.auth import load_password, pam_available +import secrets as _secrets + +from muxplex.auth import get_secret_path, load_password, pam_available # Module-level path constants (overridable in tests via monkeypatch) _system_service_path = Path("/etc/systemd/system/muxplex.service") +def reset_secret() -> None: + """Regenerate the signing secret and warn that all sessions are now invalid.""" + path = get_secret_path() + path.parent.mkdir(parents=True, exist_ok=True) + secret = _secrets.token_urlsafe(32) + path.write_text(secret + "\n") + path.chmod(0o600) + print(f"Secret written to {path}") + print("Warning: all active sessions are now invalid.") + + def show_password() -> None: """Print the current muxplex password or indicate PAM mode.""" auth_mode = os.environ.get("MUXPLEX_AUTH", "").lower() @@ -118,12 +131,18 @@ def main() -> None: sub.add_parser("show-password", help="Show the current muxplex password") + sub.add_parser( + "reset-secret", help="Regenerate signing secret (invalidates sessions)" + ) + args = parser.parse_args() if args.command == "install-service": install_service(system=args.system) elif args.command == "show-password": show_password() + elif args.command == "reset-secret": + reset_secret() else: serve( host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index af208c2..76247f6 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -1,5 +1,6 @@ """Tests for muxplex/cli.py — CLI entry point.""" +import stat from pathlib import Path from unittest.mock import patch @@ -211,6 +212,54 @@ def test_show_password_pam_mode(monkeypatch, capsys): assert "pam" in captured.out.lower() +def test_reset_secret_writes_new_secret(tmp_path, monkeypatch): + """reset_secret() writes a new secret file with content longer than 20 chars.""" + from muxplex.cli import reset_secret + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + + reset_secret() + + secret_path = fake_home / ".config" / "muxplex" / "secret" + assert secret_path.exists(), "Secret file must be created" + content = secret_path.read_text().strip() + assert len(content) > 20, f"Secret must be longer than 20 chars, got {len(content)}" + + +def test_reset_secret_sets_0600_permissions(tmp_path, monkeypatch): + """reset_secret() sets file permissions to 0o600.""" + from muxplex.cli import reset_secret + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + + reset_secret() + + secret_path = fake_home / ".config" / "muxplex" / "secret" + file_mode = stat.S_IMODE(secret_path.stat().st_mode) + assert file_mode == 0o600, f"Expected 0o600, got {oct(file_mode)}" + + +def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys): + """reset_secret() prints a warning that sessions are now invalid.""" + from muxplex.cli import reset_secret + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + + reset_secret() + + captured = capsys.readouterr() + output_lower = captured.out.lower() + assert "invalid" in output_lower or "warning" in output_lower, ( + f"Expected 'invalid' or 'warning' in output, got: {captured.out!r}" + ) + + def test_dunder_main_calls_main(): """python -m muxplex must call cli.main().""" import importlib.util