feat: add reset-secret CLI subcommand

- Add reset_secret() function that regenerates the signing secret
- Uses secrets.token_urlsafe(32) for secure key generation
- Creates parent directories if they don't exist
- Sets file permissions to 0o600 (owner read/write only)
- Prints warning that all active sessions are now invalid
- Register 'reset-secret' subparser in main() arg parser
- Add dispatch branch for 'reset-secret' command

Tests added:
- test_reset_secret_writes_new_secret: secret file exists with content > 20 chars
- test_reset_secret_sets_0600_permissions: file mode is 0o600
- test_reset_secret_prints_warning: output contains 'invalid' or 'warning'

Co-authored-by: Amplifier <amplifier@sourcegraph.com>
This commit is contained in:
Brian Krabach
2026-03-28 22:58:09 -07:00
parent 0d691eab12
commit 387e19a2a2
2 changed files with 69 additions and 1 deletions
+20 -1
View File
@@ -5,12 +5,25 @@ import os
import sys import sys
from pathlib import Path 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) # Module-level path constants (overridable in tests via monkeypatch)
_system_service_path = Path("/etc/systemd/system/muxplex.service") _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: def show_password() -> None:
"""Print the current muxplex password or indicate PAM mode.""" """Print the current muxplex password or indicate PAM mode."""
auth_mode = os.environ.get("MUXPLEX_AUTH", "").lower() 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("show-password", help="Show the current muxplex password")
sub.add_parser(
"reset-secret", help="Regenerate signing secret (invalidates sessions)"
)
args = parser.parse_args() args = parser.parse_args()
if args.command == "install-service": if args.command == "install-service":
install_service(system=args.system) install_service(system=args.system)
elif args.command == "show-password": elif args.command == "show-password":
show_password() show_password()
elif args.command == "reset-secret":
reset_secret()
else: else:
serve( serve(
host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl
+49
View File
@@ -1,5 +1,6 @@
"""Tests for muxplex/cli.py — CLI entry point.""" """Tests for muxplex/cli.py — CLI entry point."""
import stat
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -211,6 +212,54 @@ def test_show_password_pam_mode(monkeypatch, capsys):
assert "pam" in captured.out.lower() 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(): def test_dunder_main_calls_main():
"""python -m muxplex must call cli.main().""" """python -m muxplex must call cli.main()."""
import importlib.util import importlib.util