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