Files
muxplex/docs/plans/2026-03-28-auth-phase2-ui-cli.md
2026-03-29 22:12:07 -07:00

36 KiB

Auth Phase 2: Login UI + CLI — Implementation Plan

Execution: Use the subagent-driven-development workflow to implement this plan.

Goal: Build the branded login page, complete login/logout routes, and add all CLI auth commands (--auth, --session-ttl, show-password, reset-secret, startup logging).

Architecture: Phase 1 established auth.py (middleware, password/secret/cookie/PAM functions) and a stub /login route. Phase 2 replaces the stub with a fully branded login.html that auto-detects PAM vs password mode, adds the POST /login and GET /auth/logout handlers, and wires the CLI flags and subcommands that control auth behavior at startup.

Tech Stack: Python 3.11+, FastAPI, HTML/CSS/JS (no framework), argparse, pytest

Phase: 2 of 2 — complete Phase 1 (2026-03-28-auth-phase1-infrastructure.md) before starting this phase.

Design doc: docs/plans/2026-03-28-auth-design.md

Prerequisite: Phase 1 must be complete. Verify: python -m pytest muxplex/tests/ -v — all tests pass, muxplex/auth.py exists with AuthMiddleware, /login stub and /auth/mode endpoint exist in main.py.


Task 1: Create branded login.html

Files:

  • Create: muxplex/frontend/login.html
  • Modify: muxplex/tests/test_frontend_html.py (add login.html tests)

Step 1: Write the failing tests

Append to muxplex/tests/test_frontend_html.py:

# ---------------------------------------------------------------------------
# login.html tests
# ---------------------------------------------------------------------------

_LOGIN_HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "login.html"


def _login_soup() -> BeautifulSoup:
    """Parse login.html — separate from index.html soup."""
    return BeautifulSoup(_LOGIN_HTML_PATH.read_text(), "html.parser")


def test_login_html_exists() -> None:
    """login.html must exist in the frontend directory."""
    assert _LOGIN_HTML_PATH.exists(), f"Missing {_LOGIN_HTML_PATH}"


def test_login_html_has_form() -> None:
    """login.html must contain a POST form targeting /login."""
    soup = _login_soup()
    form = soup.find("form")
    assert form is not None, "Missing <form> element"
    assert form.get("method", "").lower() == "post", "Form method should be POST"
    assert form.get("action") == "/login", "Form action should be /login"


def test_login_html_has_password_autocomplete() -> None:
    """login.html password field must have autocomplete='current-password'."""
    soup = _login_soup()
    pw_input = soup.find("input", attrs={"autocomplete": "current-password"})
    assert pw_input is not None, "Missing password input with autocomplete='current-password'"


def test_login_html_has_wordmark() -> None:
    """login.html must include the muxplex wordmark SVG."""
    soup = _login_soup()
    # Check for either an <img> with wordmark or inline SVG
    img = soup.find("img", attrs={"src": lambda s: s and "wordmark" in s})
    assert img is not None, "Missing muxplex wordmark image"


def test_login_html_references_muxplex_auth() -> None:
    """login.html must reference window.MUXPLEX_AUTH for mode detection."""
    text = _LOGIN_HTML_PATH.read_text()
    assert "MUXPLEX_AUTH" in text, "login.html must reference MUXPLEX_AUTH for mode detection"


def test_login_html_has_error_display() -> None:
    """login.html must have an element for displaying auth errors."""
    soup = _login_soup()
    # Look for an element that handles error state
    text = _LOGIN_HTML_PATH.read_text()
    assert "error" in text.lower(), "login.html must handle error display (query param ?error=1)"

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_frontend_html.py -v -k "login" 2>&1 | head -20 Expected: FAIL — login.html doesn't exist yet

Step 3: Create the branded login.html

Create muxplex/frontend/login.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="theme-color" content="#0D1117" />
  <title>muxplex — login</title>
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
  <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
  <style>
    /* Inline styles — login page must render before any auth */
    *,*::before,*::after{box-sizing:border-box}
    :root {
      --bg: #0D1117;
      --bg-surface: #1A1F2B;
      --text: #F0F6FF;
      --text-muted: #8E95A3;
      --border: #2A3040;
      --border-subtle: #1E2430;
      --accent: #00D9F5;
      --accent-hover: #00b8d1;
      --err: #f85149;
      --font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
    }
    html,body {
      height: 100%; margin: 0; padding: 0;
      background: var(--bg); color: var(--text);
      font-family: var(--font-ui); font-size: 14px;
    }
    .login-wrapper {
      min-height: 100vh; display: flex;
      align-items: center; justify-content: center;
      padding: 24px;
    }
    .login-card {
      width: 100%; max-width: 380px;
      background: var(--bg-surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 40px 32px 32px;
    }
    .login-wordmark {
      display: block; margin: 0 auto 32px;
      height: 28px;
    }
    .login-field { margin-bottom: 16px; }
    .login-label {
      display: block; font-size: 13px;
      color: var(--text-muted); margin-bottom: 6px;
    }
    .login-input {
      width: 100%; padding: 10px 12px;
      background: var(--bg); color: var(--text);
      border: 1px solid var(--border-subtle);
      border-radius: 6px; font-size: 14px;
      font-family: var(--font-ui);
      outline: none; transition: border-color 150ms ease;
    }
    .login-input:focus {
      border-color: var(--accent);
    }
    .login-input[readonly] {
      opacity: 0.6; cursor: not-allowed;
    }
    .login-btn {
      width: 100%; padding: 10px 0; margin-top: 8px;
      background: var(--accent); color: var(--bg);
      border: none; border-radius: 6px;
      font-size: 14px; font-weight: 600;
      font-family: var(--font-ui);
      cursor: pointer; transition: background 150ms ease;
    }
    .login-btn:hover { background: var(--accent-hover); }
    .login-error {
      background: rgba(248,81,73,0.1);
      border: 1px solid var(--err);
      color: var(--err); border-radius: 6px;
      padding: 10px 12px; margin-bottom: 16px;
      font-size: 13px; display: none;
    }
    .login-error.visible { display: block; }
    #username-field { display: none; }
  </style>
</head>
<body>
  <div class="login-wrapper">
    <div class="login-card">
      <img src="/wordmark-on-dark.svg" alt="muxplex" class="login-wordmark" />

      <div id="login-error" class="login-error">
        Invalid credentials. Please try again.
      </div>

      <form method="post" action="/login">
        <div id="username-field" class="login-field">
          <label class="login-label" for="username">Username</label>
          <input id="username" name="username" type="text"
                 class="login-input" autocomplete="username" readonly />
        </div>

        <div class="login-field">
          <label class="login-label" for="password">Password</label>
          <input id="password" name="password" type="password"
                 class="login-input" autocomplete="current-password"
                 placeholder="Enter password" autofocus />
        </div>

        <button type="submit" class="login-btn">Sign in</button>
      </form>
    </div>
  </div>

  <script>
    // Auth mode is injected by the server as window.MUXPLEX_AUTH = {mode, user}
    (function() {
      var auth = window.MUXPLEX_AUTH || {mode: 'password', user: ''};

      // Show username field in PAM mode
      if (auth.mode === 'pam' && auth.user) {
        var field = document.getElementById('username-field');
        var input = document.getElementById('username');
        field.style.display = 'block';
        input.value = auth.user;
      }

      // Show error if redirected back with ?error=1
      if (window.location.search.indexOf('error=1') !== -1) {
        document.getElementById('login-error').classList.add('visible');
      }
    })();
  </script>
</body>
</html>

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_frontend_html.py -v -k "login" Expected: all 6 login tests PASS

Step 5: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/frontend/login.html muxplex/tests/test_frontend_html.py && git commit -m "feat: branded login.html with PAM/password mode detection"

Task 2: POST /login handler

Files:

  • Modify: muxplex/main.py
  • Modify: muxplex/tests/test_api.py

Step 1: Write the failing tests

Append to muxplex/tests/test_api.py:

# ---------------------------------------------------------------------------
# POST /login
# ---------------------------------------------------------------------------


def test_post_login_correct_password_redirects_to_root(client, monkeypatch):
    """POST /login with correct password returns 303 redirect to / with session cookie."""
    monkeypatch.setattr("muxplex.main._auth_mode", "password")
    monkeypatch.setattr("muxplex.main._auth_password", "test-pw")

    response = client.post(
        "/login",
        data={"password": "test-pw"},
        follow_redirects=False,
    )
    assert response.status_code == 303
    assert response.headers["location"] == "/"
    assert "muxplex_session" in response.cookies


def test_post_login_wrong_password_redirects_to_login_error(client, monkeypatch):
    """POST /login with wrong password returns 303 redirect to /login?error=1."""
    monkeypatch.setattr("muxplex.main._auth_mode", "password")
    monkeypatch.setattr("muxplex.main._auth_password", "test-pw")

    response = client.post(
        "/login",
        data={"password": "wrong-pw"},
        follow_redirects=False,
    )
    assert response.status_code == 303
    assert "/login" in response.headers["location"]
    assert "error=1" in response.headers["location"]


def test_post_login_pam_mode_correct_creds(client, monkeypatch):
    """POST /login in PAM mode with correct creds sets cookie and redirects."""
    monkeypatch.setattr("muxplex.main._auth_mode", "pam")
    monkeypatch.setattr(
        "muxplex.auth.authenticate_pam",
        lambda u, p: True,
    )

    response = client.post(
        "/login",
        data={"username": "testuser", "password": "correct"},
        follow_redirects=False,
    )
    assert response.status_code == 303
    assert response.headers["location"] == "/"
    assert "muxplex_session" in response.cookies


def test_post_login_pam_mode_wrong_creds(client, monkeypatch):
    """POST /login in PAM mode with wrong creds redirects to /login?error=1."""
    monkeypatch.setattr("muxplex.main._auth_mode", "pam")
    monkeypatch.setattr(
        "muxplex.auth.authenticate_pam",
        lambda u, p: False,
    )

    response = client.post(
        "/login",
        data={"username": "testuser", "password": "wrong"},
        follow_redirects=False,
    )
    assert response.status_code == 303
    assert "error=1" in response.headers["location"]

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "post_login" 2>&1 | head -20 Expected: FAIL — 405 Method Not Allowed (no POST handler for /login yet)

Step 3: Add the POST /login handler

In muxplex/main.py, add from fastapi import Request to the existing FastAPI imports if not already present. Then add this route right after the existing GET /login route:

@app.post("/login")
async def login_submit(request: Request):
    """Handle login form submission."""
    from muxplex.auth import authenticate_pam, create_session_cookie

    form = await request.form()
    username = form.get("username", "")
    password = form.get("password", "")

    # Validate credentials
    if _auth_mode == "pam":
        ok = authenticate_pam(str(username), str(password))
    else:
        ok = str(password) == _auth_password

    if not ok:
        from starlette.responses import RedirectResponse

        return RedirectResponse("/login?error=1", status_code=303)

    # Success — set session cookie and redirect to /
    cookie = create_session_cookie(_auth_secret, _auth_ttl)
    from starlette.responses import RedirectResponse

    response = RedirectResponse("/", status_code=303)
    response.set_cookie(
        "muxplex_session",
        cookie,
        httponly=True,
        samesite="strict",
        max_age=_auth_ttl if _auth_ttl > 0 else None,
    )
    return response

Note: The RedirectResponse import may already be available from starlette.responses (used in auth.py). Use whatever import pattern is cleanest — either add to the top-level imports or keep the local imports. Prefer adding from starlette.responses import RedirectResponse to the module-level imports at the top.

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "post_login" Expected: all 4 tests PASS

Step 5: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: POST /login handler for PAM and password modes"

Task 3: GET /auth/logout

Files:

  • Modify: muxplex/main.py
  • Modify: muxplex/tests/test_api.py

Step 1: Write the failing tests

Append to muxplex/tests/test_api.py:

# ---------------------------------------------------------------------------
# GET /auth/logout
# ---------------------------------------------------------------------------


def test_logout_redirects_to_login(client):
    """GET /auth/logout returns 303 redirect to /login."""
    response = client.get("/auth/logout", follow_redirects=False)
    assert response.status_code == 303
    assert "/login" in response.headers["location"]


def test_logout_clears_session_cookie(client):
    """GET /auth/logout deletes the muxplex_session cookie (max-age=0)."""
    response = client.get("/auth/logout", follow_redirects=False)
    # Check Set-Cookie header clears the cookie
    set_cookie = response.headers.get("set-cookie", "")
    assert "muxplex_session" in set_cookie
    # Cookie should be expired (max-age=0 or empty value)
    assert 'max-age=0' in set_cookie.lower() or '=""' in set_cookie or "=''" in set_cookie

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "logout" 2>&1 | head -20 Expected: FAIL — 404 or 307 (no /auth/logout route yet)

Step 3: Add the logout route

In muxplex/main.py, add this route after the POST /login handler (and before the static file mount):

@app.get("/auth/logout")
async def logout():
    """Clear the session cookie and redirect to login."""
    from starlette.responses import RedirectResponse

    response = RedirectResponse("/login", status_code=303)
    response.delete_cookie("muxplex_session")
    return response

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "logout" Expected: both tests PASS

Step 5: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: GET /auth/logout clears session cookie"

Task 4: Replace /login stub with branded login.html serving

Files:

  • Modify: muxplex/main.py
  • Modify: muxplex/tests/test_api.py

Step 1: Write the failing test

Append to muxplex/tests/test_api.py:

# ---------------------------------------------------------------------------
# GET /login serves branded page with injected auth mode
# ---------------------------------------------------------------------------


def test_get_login_injects_muxplex_auth(client):
    """GET /login HTML must contain window.MUXPLEX_AUTH with the auth mode."""
    response = client.get("/login")
    assert response.status_code == 200
    assert "MUXPLEX_AUTH" in response.text
    assert '"mode"' in response.text

Step 2: Run to verify it fails

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py::test_get_login_injects_muxplex_auth -v Expected: FAIL — current stub doesn't have MUXPLEX_AUTH

Step 3: Replace the GET /login handler

In muxplex/main.py, replace the existing login_page() function with:

@app.get("/login", response_class=HTMLResponse)
async def login_page():
    """Serve the branded login page with auth mode injected."""
    import json

    html = (_FRONTEND_DIR / "login.html").read_text()

    username = ""
    if _auth_mode == "pam":
        username = pwd.getpwuid(os.getuid()).pw_name

    mode_data = json.dumps({"mode": _auth_mode, "user": username})
    # Inject auth mode before </head> so the inline script can read it
    html = html.replace(
        "</head>",
        f"<script>window.MUXPLEX_AUTH = {mode_data};</script>\n</head>",
    )
    return HTMLResponse(html)

Note: _FRONTEND_DIR is already defined at the bottom of main.py as pathlib.Path(__file__).parent / "frontend". It's used for the StaticFiles mount. You need to move this variable definition above the routes section so login_page() can reference it, or define it separately near the top. The simplest change: move the _FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend" line to just after the imports/config section (around line 50), keeping the app.mount(...) line at the bottom.

Step 4: Run to verify it passes

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "login" Expected: all login tests PASS (including the old test_get_login_returns_200_html)

Step 5: Optionally remove the /auth/mode endpoint

Since login.html now reads window.MUXPLEX_AUTH instead of fetching /auth/mode, the endpoint is redundant. However, it's harmless and could be useful for API clients. Keep it but it's no longer required for the login flow.

Step 6: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: /login GET serves branded login.html with injected auth mode"

Task 5: CLI: update host default and add auth flags

Files:

  • Modify: muxplex/cli.py
  • Modify: muxplex/tests/test_cli.py

Step 1: Write the failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# Auth CLI flags
# ---------------------------------------------------------------------------


def test_main_default_host_is_localhost():
    """Default --host must be 127.0.0.1 (changed from 0.0.0.0)."""
    from muxplex.cli import main

    with patch("muxplex.cli.serve") as mock_serve:
        with patch("sys.argv", ["muxplex"]):
            main()
        call_kwargs = mock_serve.call_args
        assert call_kwargs[1]["host"] == "127.0.0.1" or call_kwargs[0][0] == "127.0.0.1"


def test_main_passes_auth_flag():
    """main() with --auth password must forward auth='password' to serve()."""
    from muxplex.cli import main

    with patch("muxplex.cli.serve") as mock_serve:
        with patch("sys.argv", ["muxplex", "--auth", "password"]):
            main()
        _, kwargs = mock_serve.call_args
        assert kwargs.get("auth") == "password"


def test_main_passes_session_ttl_flag():
    """main() with --session-ttl 3600 must forward session_ttl=3600 to serve()."""
    from muxplex.cli import main

    with patch("muxplex.cli.serve") as mock_serve:
        with patch("sys.argv", ["muxplex", "--session-ttl", "3600"]):
            main()
        _, kwargs = mock_serve.call_args
        assert kwargs.get("session_ttl") == 3600

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "default_host or auth_flag or session_ttl" 2>&1 | head -20 Expected: FAIL — default host is still 0.0.0.0, no --auth or --session-ttl flags

Step 3: Update cli.py

In muxplex/cli.py, make these changes:

  1. Change the serve() signature to accept auth params:
def serve(host: str = "127.0.0.1", port: int = 8088, auth: str = "pam", session_ttl: int = 604800) -> None:
    """Start the muxplex server."""
    import uvicorn  # noqa: PLC0415

    os.environ.setdefault("MUXPLEX_PORT", str(port))
    if auth:
        os.environ.setdefault("MUXPLEX_AUTH", auth)
    os.environ.setdefault("MUXPLEX_SESSION_TTL", str(session_ttl))

    from muxplex.main import app  # noqa: PLC0415

    print(f"  muxplex → http://{host}:{port}")
    uvicorn.run(app, host=host, port=port, log_level="warning")
  1. Change the --host default:
    parser.add_argument(
        "--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)"
    )
  1. Add new arguments after the --port argument:
    parser.add_argument(
        "--auth",
        choices=["pam", "password"],
        default="pam",
        help="Auth mode: pam (default) or password",
    )
    parser.add_argument(
        "--session-ttl",
        type=int,
        default=604800,
        help="Session cookie TTL in seconds (default: 604800 = 7 days, 0 = browser session)",
    )
  1. Update the serve() call in main() to pass the new args:
    if args.command == "install-service":
        install_service(system=args.system)
    else:
        serve(host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl)

Step 4: Update the existing test that checks the old default

The existing test test_main_calls_serve_by_default asserts host="0.0.0.0". Update it:

In muxplex/tests/test_cli.py, change:

        mock_serve.assert_called_once_with(host="0.0.0.0", port=8088)

to:

        mock_serve.assert_called_once_with(host="127.0.0.1", port=8088, auth="pam", session_ttl=604800)

Step 5: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v Expected: all tests PASS (including updated existing tests)

Step 6: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat(cli): add --auth and --session-ttl flags, change --host default to 127.0.0.1"

Task 6: CLI: show-password subcommand

Files:

  • Modify: muxplex/cli.py
  • Modify: muxplex/tests/test_cli.py

Step 1: Write the failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# show-password subcommand
# ---------------------------------------------------------------------------


def test_show_password_prints_password_from_file(tmp_path, monkeypatch, capsys):
    """show-password prints the password when the file exists."""
    from muxplex.cli import main

    # Set up a fake password file
    fake_home = tmp_path / "home"
    fake_home.mkdir()
    monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
    pw_path = fake_home / ".config" / "muxplex" / "password"
    pw_path.parent.mkdir(parents=True, exist_ok=True)
    pw_path.write_text("my-test-password\n")
    pw_path.chmod(0o600)

    # Force password mode
    monkeypatch.setenv("MUXPLEX_AUTH", "password")

    with patch("sys.argv", ["muxplex", "show-password"]):
        main()

    captured = capsys.readouterr()
    assert "my-test-password" in captured.out


def test_show_password_no_file(tmp_path, monkeypatch, capsys):
    """show-password prints a helpful message when no password file exists."""
    from muxplex.cli import main

    fake_home = tmp_path / "home"
    fake_home.mkdir()
    monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
    monkeypatch.setenv("MUXPLEX_AUTH", "password")

    with patch("sys.argv", ["muxplex", "show-password"]):
        main()

    captured = capsys.readouterr()
    assert "no password" in captured.out.lower() or "not found" in captured.out.lower()


def test_show_password_pam_mode(monkeypatch, capsys):
    """show-password in PAM mode prints that PAM is active."""
    from muxplex.cli import main

    monkeypatch.delenv("MUXPLEX_AUTH", raising=False)
    monkeypatch.setattr("muxplex.auth.pam_available", lambda: True)

    with patch("sys.argv", ["muxplex", "show-password"]):
        main()

    captured = capsys.readouterr()
    assert "pam" in captured.out.lower()

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "show_password" 2>&1 | head -20 Expected: FAIL — show-password subcommand doesn't exist

Step 3: Add the show-password subcommand

In muxplex/cli.py, add the function:

def show_password() -> None:
    """Show the current muxplex password."""
    from muxplex.auth import load_password, pam_available

    auth_mode = os.environ.get("MUXPLEX_AUTH", "").lower()
    if auth_mode != "password" and pam_available():
        print("Auth mode: PAM — no password file used")
        return

    pw = load_password()
    if pw:
        print(f"Password: {pw}")
    else:
        print("No password file found. Start muxplex to auto-generate one.")

Then register it as a subcommand in main(). Add after the install-service subparser:

    sub.add_parser("show-password", help="Show the current muxplex password")

And in the command dispatch:

    if args.command == "install-service":
        install_service(system=args.system)
    elif args.command == "show-password":
        show_password()
    else:
        serve(host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl)

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "show_password" Expected: all 3 tests PASS

Step 5: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat(cli): add show-password subcommand"

Task 7: CLI: reset-secret subcommand

Files:

  • Modify: muxplex/cli.py
  • Modify: muxplex/tests/test_cli.py

Step 1: Write the failing tests

Append to muxplex/tests/test_cli.py:

# ---------------------------------------------------------------------------
# reset-secret subcommand
# ---------------------------------------------------------------------------


def test_reset_secret_writes_new_secret(tmp_path, monkeypatch, capsys):
    """reset-secret writes a new secret file."""
    from muxplex.cli import main

    fake_home = tmp_path / "home"
    fake_home.mkdir()
    monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))

    with patch("sys.argv", ["muxplex", "reset-secret"]):
        main()

    secret_path = fake_home / ".config" / "muxplex" / "secret"
    assert secret_path.exists()
    content = secret_path.read_text().strip()
    assert len(content) > 20


def test_reset_secret_sets_0600_permissions(tmp_path, monkeypatch, capsys):
    """reset-secret sets the secret file to mode 0600."""
    from muxplex.cli import main

    fake_home = tmp_path / "home"
    fake_home.mkdir()
    monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))

    with patch("sys.argv", ["muxplex", "reset-secret"]):
        main()

    secret_path = fake_home / ".config" / "muxplex" / "secret"
    mode = stat.S_IMODE(secret_path.stat().st_mode)
    assert mode == 0o600


def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys):
    """reset-secret prints a warning about invalidated sessions."""
    from muxplex.cli import main

    fake_home = tmp_path / "home"
    fake_home.mkdir()
    monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))

    with patch("sys.argv", ["muxplex", "reset-secret"]):
        main()

    captured = capsys.readouterr()
    assert "invalid" in captured.out.lower() or "warning" in captured.out.lower()

Step 2: Run tests to verify they fail

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "reset_secret" 2>&1 | head -20 Expected: FAIL — reset-secret subcommand doesn't exist

Step 3: Add the reset-secret subcommand

In muxplex/cli.py, add the function:

def reset_secret() -> None:
    """Regenerate the signing secret, invalidating all active sessions."""
    import secrets as _secrets

    from muxplex.auth import get_secret_path

    path = get_secret_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    new_secret = _secrets.token_urlsafe(32)
    path.write_text(new_secret + "\n")
    path.chmod(0o600)
    print(f"New signing secret written to {path}")
    print("Warning: all active sessions are now invalid.")

Register it as a subcommand. Add after the show-password subparser:

    sub.add_parser("reset-secret", help="Regenerate signing secret (invalidates sessions)")

And in the command dispatch:

    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)

Also add import stat to the test file imports if not already present.

Step 4: Run tests to verify they pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "reset_secret" Expected: all 3 tests PASS

Step 5: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat(cli): add reset-secret subcommand"

Task 8: Startup auth logging

Files:

  • Modify: muxplex/main.py (refine _resolve_auth logging)
  • Modify: muxplex/tests/test_auth.py

Step 1: Write the failing tests

Append to muxplex/tests/test_auth.py:

# ---------------------------------------------------------------------------
# Startup auth logging (via _resolve_auth)
# ---------------------------------------------------------------------------


def test_resolve_auth_pam_mode_logs_pam(monkeypatch, capsys, tmp_path):
    """_resolve_auth() prints PAM auth line when PAM is available."""
    monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
    monkeypatch.delenv("MUXPLEX_AUTH", raising=False)
    monkeypatch.delenv("MUXPLEX_PASSWORD", raising=False)
    monkeypatch.setattr("muxplex.auth.pam_available", lambda: True)

    # Import after patching
    from muxplex.main import _resolve_auth

    mode, pw = _resolve_auth()
    assert mode == "pam"
    captured = capsys.readouterr()
    assert "PAM" in captured.err


def test_resolve_auth_env_password_logs_env(monkeypatch, capsys, tmp_path):
    """_resolve_auth() prints env password line when MUXPLEX_PASSWORD is set."""
    monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
    monkeypatch.setenv("MUXPLEX_AUTH", "password")
    monkeypatch.setenv("MUXPLEX_PASSWORD", "from-env")

    from muxplex.main import _resolve_auth

    mode, pw = _resolve_auth()
    assert mode == "password"
    assert pw == "from-env"
    captured = capsys.readouterr()
    assert "env" in captured.err.lower()


def test_resolve_auth_file_password_logs_file(monkeypatch, capsys, tmp_path):
    """_resolve_auth() prints file password line when password file exists."""
    monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
    monkeypatch.setenv("MUXPLEX_AUTH", "password")
    monkeypatch.delenv("MUXPLEX_PASSWORD", raising=False)

    pw_path = tmp_path / ".config" / "muxplex" / "password"
    pw_path.parent.mkdir(parents=True, exist_ok=True)
    pw_path.write_text("file-password\n")
    pw_path.chmod(0o600)

    from muxplex.main import _resolve_auth

    mode, pw = _resolve_auth()
    assert mode == "password"
    assert pw == "file-password"
    captured = capsys.readouterr()
    assert "file" in captured.err.lower() or "password" in captured.err.lower()


def test_resolve_auth_generates_password_as_last_resort(monkeypatch, capsys, tmp_path):
    """_resolve_auth() auto-generates a password when nothing else is available."""
    monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
    monkeypatch.setenv("MUXPLEX_AUTH", "password")
    monkeypatch.delenv("MUXPLEX_PASSWORD", raising=False)

    from muxplex.main import _resolve_auth

    mode, pw = _resolve_auth()
    assert mode == "password"
    assert len(pw) > 10
    captured = capsys.readouterr()
    assert "generated" in captured.err.lower()
    # The generated password should be printed so the user can see it
    assert pw in captured.err

Step 2: Run tests to verify they fail or pass

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "resolve_auth" 2>&1 | head -30 Expected: These tests verify the _resolve_auth function added in Phase 1 Task 7. They may pass already if the logging was correctly implemented. If they fail, fix the _resolve_auth function.

Step 3: Refine _resolve_auth logging if needed

Verify the _resolve_auth() function in muxplex/main.py prints exactly these lines to stderr:

  • PAM available: muxplex auth: PAM (user: {username})
  • Env password: muxplex auth: password (env)
  • File password: muxplex auth: password (file: ~/.config/muxplex/password)
  • Auto-generated: muxplex auth: password generated — {password} — saved to ~/.config/muxplex/password

Update the function if the format doesn't match. Fix the file_pw logging line — the Phase 1 plan had a bug (it printed load_password.__module__ instead of the file path). It should be:

    file_pw = load_password()
    if file_pw:
        from muxplex.auth import get_password_path
        print(f"  muxplex auth: password (file: {get_password_path()})", file=sys.stderr)
        return "password", file_pw

Step 4: Run all tests

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "resolve_auth" Expected: all 4 tests PASS

Step 5: Run the full test suite

Run: cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v Expected: ALL tests pass across all test files

Step 6: Commit

cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_auth.py && git commit -m "feat: startup auth mode logging with auto-generated password display"

Phase 2 Complete Checklist

After all 8 tasks:

  • muxplex/frontend/login.html exists — branded dark theme, wordmark, PAM/password mode detection
  • POST /login works — correct creds → cookie + redirect to /, wrong creds → redirect to /login?error=1
  • GET /auth/logout works — clears cookie, redirects to /login
  • GET /login serves login.html with window.MUXPLEX_AUTH injected
  • --host defaults to 127.0.0.1
  • --auth and --session-ttl flags work
  • muxplex show-password prints the password or PAM message
  • muxplex reset-secret regenerates the signing key with warning
  • Startup prints one clear auth mode line to stderr
  • All tests pass: python -m pytest muxplex/tests/ -v
  • 8 clean commits with conventional commit messages

End-to-End Smoke Test

After both phases are complete, manually verify:

  1. cd /home/bkrabach/dev/web-tmux/muxplex && python -m muxplex --host 0.0.0.0 — should print auth mode line
  2. Open http://localhost:8088 — should load the dashboard (localhost bypass)
  3. Open from another device on the LAN — should redirect to /login
  4. Log in with the displayed password — should redirect to dashboard
  5. muxplex show-password — prints the password
  6. muxplex reset-secret — prints warning, old browser session should fail

Deferred

  • HTTPS/TLS support
  • Rate limiting on login endpoint
  • Remember-me longer TTL
  • Admin reset flow
  • install-service auth-aware unit files (systemd EnvironmentFile, launchd plist)