Commit Graph

172 Commits

Author SHA1 Message Date
Brian Krabach 6144f39955 feat: hover preview popover on dashboard tiles
Desktop only (guarded by ontouchstart check). On mouseenter of a
session tile, shows a floating popover with the full tmux snapshot
(all lines, not the cropped 20-line preview). Positioned right of
tile if space, else left. 100ms delay prevents flicker on brush-over.
Data from _currentSessions (already in memory, zero API calls).

Also calls hidePreview() in openSession() so the popover is cleaned
up when switching to fullscreen view.
2026-03-29 14:42:30 -07:00
Brian Krabach 4b4b914858 fix: session switch race — mount terminal AFTER /connect POST completes
_openTerminal(name) was inside a 260ms setTimeout that fired before the
/connect POST completed. The WebSocket connected to the OLD ttyd (still
serving the previous session). Fix: animation runs concurrently with
/connect as before, but _openTerminal only fires after BOTH resolve.

Sequence: start animation + POST concurrently → await POST → await
animation → mount terminal. The new ttyd is guaranteed to be serving
the correct session before the WebSocket connects.

Added timerId null-check so animDone resolves immediately in test envs
where setTimeout is stubbed, preventing test hangs.
2026-03-29 14:17:10 -07:00
Brian Krabach ab1f579ea9 feat: Android-only rAF-batched touch scroll for terminal
Android batches touchmove events irregularly — firing one WheelEvent per
touchmove caused burst delivery (5 events → 5 WheelEvents → visible jump).
Fix: accumulate delta in touchmove, dispatch exactly one WheelEvent per
requestAnimationFrame tick (≤60fps). Remainder carries over to next frame.

UA-gated (/Android/i) — iOS and macOS are completely unaffected.
WheelEvent path → xterm.js mouse reporting → tmux (correct inside path).
e.preventDefault() blocks outer-container scroll on Android.
2026-03-29 06:29:09 -07:00
Brian Krabach d1d700e7fd test: clean up WebSocket auth test hygiene (unused fixture, deprecated cookies kwarg) 2026-03-29 04:30:25 -07:00
Brian Krabach 6c0bc80f21 fix(auth): protect WebSocket /terminal/ws with session cookie check
BaseHTTPMiddleware only handles HTTP scope — WebSocket connections
bypassed auth entirely. Inline check before websocket.accept() reads
the muxplex_session cookie and closes with code 4001 if invalid.
Localhost (127.0.0.1, ::1) still bypasses as intended.
2026-03-29 04:23:41 -07:00
Brian Krabach 575cebbcac fix: resolve_auth file password log prints actual path, not module name
Add `get_password_path` to top-level imports in main.py and fix the file
password resolution branch to log the actual file path instead of
`load_password.__module__` (which printed "muxplex.auth").

Also adds 4 tests covering all _resolve_auth logging paths:
- test_resolve_auth_pam_mode_logs_pam
- test_resolve_auth_env_password_logs_env
- test_resolve_auth_file_password_logs_file
- test_resolve_auth_generates_password_as_last_resort
2026-03-28 23:06:27 -07:00
Brian Krabach 387e19a2a2 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>
2026-03-28 22:58:09 -07:00
Brian Krabach 0d691eab12 feat: add show-password CLI subcommand
Adds 'show-password' subcommand to the muxplex CLI that:
- Prints 'Auth mode: PAM — no password file used' when MUXPLEX_AUTH
  is not 'password' and PAM is available
- Prints 'Password: <pw>' when a password file exists
- Prints guidance to auto-generate when no password file found

Also imports load_password and pam_available at module level in cli.py
to enable testable patching via muxplex.cli namespace.

Tests added:
- test_show_password_prints_password_from_file
- test_show_password_no_file
- test_show_password_pam_mode
2026-03-28 22:53:29 -07:00
Brian Krabach 159b2c3618 feat: change --host default to 127.0.0.1, add --auth and --session-ttl flags
- Change serve() signature: host='127.0.0.1', add auth='pam', session_ttl=604800
- Inside serve(): set MUXPLEX_AUTH and MUXPLEX_SESSION_TTL env defaults
- Change --host CLI default from '0.0.0.0' to '127.0.0.1'
- Add --auth argument with choices=['pam', 'password'], default='pam'
- Add --session-ttl argument, type=int, default=604800 (7 days; 0=browser session)
- Update serve() call in main() to forward auth and session_ttl
- Update test_main_calls_serve_by_default assertion for new defaults
- Add test_main_default_host_is_localhost
- Add test_main_passes_auth_flag
- Add test_main_passes_session_ttl_flag

Co-authored-by: Amplifier <amplifier@amplified.dev>
2026-03-28 22:46:23 -07:00
Brian Krabach fd55a71958 feat: serve branded login.html with injected window.MUXPLEX_AUTH
Replace login_page() stub with handler that:
- Reads login.html from _FRONTEND_DIR
- Determines username (PAM mode: current user, else empty string)
- Builds mode_data with json.dumps({mode, user})
- Injects <script>window.MUXPLEX_AUTH = {...};</script> before </head>
- Returns HTMLResponse(html)

Move _FRONTEND_DIR definition above routes so login_page() can reference it.
Keep app.mount() at bottom and /auth/mode endpoint.

Add test: test_get_login_injects_muxplex_auth
  - Asserts status 200, MUXPLEX_AUTH in text, '"mode"' in text

All 45 tests pass.
2026-03-28 22:40:36 -07:00
Brian Krabach cf79d9fc9f docs(tests): clarify why logout tests bypass the shared client fixture
The client fixture pre-injects a valid muxplex_session cookie to make
most tests pass auth middleware. Logout tests intentionally create their
own TestClient (without the cookie) to prove the endpoint works
correctly for unauthenticated and expired-session requests.

Co-authored-by: Amplifier <amplifier@sourcegraph.com>
2026-03-28 22:36:09 -07:00
Brian Krabach 2ca4420536 feat: add GET /auth/logout route that clears session cookie and redirects to /login
- Add @app.get('/auth/logout') handler in muxplex/main.py after POST /login
- Creates RedirectResponse('/login', status_code=303)
- Calls response.delete_cookie('muxplex_session') to clear the auth cookie
- Add two tests in test_api.py:
  - test_logout_redirects_to_login: verifies 303 status and /login in location header
  - test_logout_clears_session_cookie: verifies Set-Cookie has muxplex_session with max-age=0
- Route is already exempt from AuthMiddleware via _AUTH_EXEMPT_PATHS in auth.py

Co-authored-by: Amplifier <amplifier@sourcegraph.com>
2026-03-28 22:30:39 -07:00
Brian Krabach b5d700214b feat: add POST /login handler with PAM and password mode auth
- Import authenticate_pam and create_session_cookie from muxplex.auth
- Import Form, Request from fastapi; RedirectResponse from starlette.responses
- Add @app.post('/login') handler that reads username and password form fields
- In PAM mode: delegates to authenticate_pam(username, password)
- In password mode: compares password to _auth_password
- On success: creates signed muxplex_session cookie (httponly, samesite=strict),
  redirects to / with 303
- On failure: redirects to /login?error=1 with 303
- Add 4 tests covering all branches (password success/failure, PAM success/failure)

Closes task-2-post-login-handler
2026-03-28 22:20:38 -07:00
Brian Krabach d5ffced6a5 feat: add branded login.html with PAM/password mode detection
- Create muxplex/frontend/login.html with:
  - POST form targeting /login with password autocomplete='current-password'
  - Wordmark img referencing /wordmark-on-dark.svg
  - Hidden username field shown in PAM mode via window.MUXPLEX_AUTH
  - Error banner shown when ?error=1 query param is present
  - Inline dark-theme CSS with design token variables (--bg, --accent, etc.)
  - Inline <script> reading window.MUXPLEX_AUTH for auth mode detection

- Add 6 login tests to muxplex/tests/test_frontend_html.py:
  - test_login_html_exists
  - test_login_html_has_form
  - test_login_html_has_password_autocomplete
  - test_login_html_has_wordmark
  - test_login_html_references_muxplex_auth
  - test_login_html_has_error_display

All 6 tests pass. Existing 15 index.html tests unaffected.

Co-authored-by: Amplifier <amplifier@amplified.dev>
2026-03-28 22:13:14 -07:00
Brian Krabach dd0507d8e1 fix: remove unused JSONResponse alias causing ruff F401 lint error 2026-03-28 21:56:59 -07:00
Brian Krabach ba7101292c feat: add /login stub and /auth/mode endpoint 2026-03-28 21:53:00 -07:00
Brian Krabach 88b21832f7 feat: wire auth middleware into FastAPI app
- Add _resolve_auth() with PAM → MUXPLEX_PASSWORD env → password file → auto-generate fallback chain
- MUXPLEX_AUTH=password forces password mode regardless of PAM availability
- MUXPLEX_SESSION_TTL controls session cookie TTL (default 604800 = 7 days)
- app.add_middleware(AuthMiddleware, ...) called after app creation
- Update client fixture to set a valid session cookie for test compatibility
- Add test_non_localhost_without_auth_gets_redirected integration test

Co-authored-by: Amplifier <amplifier@amplified.dev>
2026-03-28 21:47:01 -07:00
Brian Krabach 4e590345c3 fix(auth): remove Host-header auth bypass; inject client IP in tests via ASGI middleware
The server_host check (request.url.hostname) read from the user-controlled
HTTP Host header, allowing any attacker to bypass authentication by sending
'Host: 127.0.0.1'. Remove it entirely — request.client.host is the
socket-level IP and cannot be forged.

Fix the localhost test by introducing _InjectClientMiddleware, a thin ASGI
wrapper that writes a real IP into the scope's 'client' field. This lets the
test exercise the actual localhost check with 127.0.0.1 rather than relying
on the URL hostname.

Also:
- Promote _LOCALHOST_ADDRS to module-level constant (no-op per request)
- Add clarifying comment on auth_header[6:] magic slice
- Replace per-request cookies= kwarg with client.cookies.set() (Starlette deprecation)
2026-03-28 21:40:22 -07:00
Brian Krabach 22be933ebf feat(auth): request middleware with localhost bypass and session cookie check
- Add AuthMiddleware class extending BaseHTTPMiddleware
- Localhost bypass: checks both client.host and url.hostname for 127.0.0.1/::1/localhost
- Exempt paths: /login, /auth/mode, /auth/logout bypass auth
- Valid session cookie (muxplex_session) passes through
- Basic auth header: valid password passes, invalid returns 401
- API clients (Accept: application/json) get 401 on auth failure
- Browser clients get 307 redirect to /login on auth failure
- _check_credentials dispatches to PAM or password comparison
- Add 8 middleware tests to test_auth.py

Co-authored-by: Amplifier <amplifier@example.com>
2026-03-28 21:31:48 -07:00
Brian Krabach 7cf2a5882a style(auth): apply code quality suggestions from review
- auth.py: replace 'import os as _os' with plain 'import os' inside
  authenticate_pam() — the alias convention is for module-level private
  names, not function-scoped imports
- test_auth.py: move 'import pwd' from two test functions to module-level
  imports, consistent with existing 'import os' placement
- test_auth.py: make wrong-user test robust against running as root —
  derives 'wrong_user' dynamically so the test holds in Docker CI
  environments where the runner may be root
2026-03-28 21:25:16 -07:00
Brian Krabach 49475ba352 feat(auth): PAM authentication with running-user check 2026-03-28 21:19:53 -07:00
Brian Krabach 79dade7af7 style(auth): clarify ttl_seconds is signing-time passthrough in create_session_cookie 2026-03-28 21:16:16 -07:00
Brian Krabach 22816cd1d5 feat(auth): session cookie signing and verification 2026-03-28 21:11:14 -07:00
Brian Krabach 9adb459d99 fix(auth): use _config_dir() in load_or_create_secret for consistent 0700 dir permissions
Replace bare path.parent.mkdir() (no mode, defaults to umask ~0755) with
_config_dir() which always creates ~/.config/muxplex with mode=0o700.
This matches the pattern already used by generate_and_save_password() and
ensures the config directory is never left traversable by other users.

Also update the module docstring to reflect that the module now manages
both passwords and signing secrets.
2026-03-28 21:04:31 -07:00
Brian Krabach 91e57431e4 feat(auth): signing secret file management 2026-03-28 21:00:56 -07:00
Brian Krabach 750345a763 test(auth): add coverage for config dir 0700 permissions 2026-03-28 20:57:15 -07:00
Brian Krabach 60300f64ea fix(auth): enforce mode 0700 on config dir mkdir to match docstring 2026-03-28 20:54:57 -07:00
Brian Krabach c7050a2b02 refactor(auth): eliminate dead _config_dir() and trim docstring
- Wire _config_dir() into generate_and_save_password() so the helper is no longer dead code; the 0700 directory mode is now enforced correctly
- Trim module docstring to reflect what the file actually contains (password file management only)
2026-03-28 20:52:59 -07:00
Brian Krabach 3b248e2a63 feat(auth): password file management
Add muxplex/auth.py with password file management functions:
- get_password_path(): returns ~/.config/muxplex/password
- load_password(): reads password file, returns None if not found
- generate_and_save_password(): generates random password, writes with 0600 permissions

Add muxplex/tests/test_auth.py with 5 tests covering all functions.

Co-authored-by: Amplifier <amplifier@invalid>
2026-03-28 20:49:04 -07:00
Brian Krabach 4cd013fcc5 chore: add python-pam and itsdangerous dependencies 2026-03-28 20:42:33 -07:00
Brian Krabach 0dbf8ca2cb docs: add authentication design document
Covers PAM + password auth modes, localhost bypass, session cookies,
service installation (systemd/launchd), and testing strategy.
2026-03-28 19:26:33 -07:00
Brian Krabach c6a5507daf revert: remove JS touch scroll handlers — back to mouse-only baseline
Removing terminal WheelEvent dispatch and sidebar scrollTop handlers.
CSS fixes retained (touch-action:pan-y, overscroll-behavior:contain,
flex-shrink:0). Testing mouse-only baseline across all devices before
deciding on the right mobile scroll approach.
2026-03-28 16:38:54 -07:00
Brian Krabach b2b9fd1746 fix: smooth Android terminal scroll via delta accumulation
Old: variable deltaY*3 per touchmove — Android batches events causing
burst-scroll then pause (jumpy). New: accumulate pixels across events,
fire fixed deltaY=120 per SCROLL_PX pixels. Each scroll step is
identical regardless of when Android delivers touchmove events.

SCROLL_PX=20 → ~40px swipe = 2 scroll clicks. Accumulator resets on
touchstart and touchend. macOS/iPad/mouse-wheel path unchanged.
2026-03-28 16:19:30 -07:00
Brian Krabach c1701bf26b fix: sidebar-item flex-shrink:0 — cards were compressing to fit, eliminating scroll
Without flex-shrink:0, flex compresses all sidebar cards proportionally
when the container is shorter than their total height. Cards visually
fit the screen with no overflow — scrollTop stays 0, touch handler
has nothing to scroll. One property restores the intended overflow.
2026-03-28 16:04:02 -07:00
Brian Krabach 383b28c85e fix: sidebar touch scroll via scrollTop — bypasses broken CSS path
position:fixed + overflow:hidden on .session-sidebar in mobile overlay
mode prevents Android Chrome from routing native touch-scroll to the
child .sidebar-list (overflow-y:auto). Direct scrollTop manipulation
always works. Mirrors the terminal WheelEvent approach: take JS control
rather than relying on native CSS touch-scroll routing.
2026-03-28 15:52:37 -07:00
Brian Krabach 195912edf9 fix: touch scroll dispatches WheelEvent instead of scrollLines
scrollLines() only moves xterm.js's local scrollback buffer (outside).
WheelEvent dispatch goes through xterm.js's mouse reporting path —
if tmux mouse mode is on, escape sequences reach the PTY so tmux and
applications (vim, less, etc.) handle the scroll (inside).

Same behavior as mouse wheel on desktop.
2026-03-28 15:01:01 -07:00
Brian Krabach 3d91bd5c68 feat: touch scroll for terminal and sidebar on mobile
Terminal: add touchstart/touchmove/touchend IIFE on #terminal-container.
Vertical swipe delta translated to _term.scrollLines(n). touchmove is
non-passive (required for e.preventDefault to block page scroll while
swiping inside terminal). mouse wheel scroll unaffected (separate wheel
event path in xterm.js).

Sidebar: add touch-action:pan-y + overscroll-behavior:contain to
.sidebar-list. Tells browser explicitly that vertical panning is allowed
and prevents scroll chaining when reaching list ends.
2026-03-28 14:37:57 -07:00
Brian Krabach 4ffdc1bd99 fix: three mobile/WSL bugs reported by tester
- Mobile idle tiles: display:none → height:36px so session content is
  visible (all sessions are 'idle' with zero bells — was blank on iPhone)
- .tile-pre dead CSS: merge font/color rules into .tile-body pre (the
  actual HTML selector) so snapshot text renders with monospace + muted
  color; update mobile active-tier selector to match
- systemd PATH: strip /mnt/ entries from PATH before writing service
  unit file — WSL paths with spaces caused systemd to reject the line
2026-03-28 09:03:33 -07:00
Brian Krabach 38c78fb575 fix: WebSocket proxy drops text frames — receive_bytes() silently fails on auth token
terminal.js sends the ttyd auth token as a TEXT WebSocket frame. The proxy's
client_to_ttyd() called receive_bytes() which fails silently on text frames,
swallows the exception, and exits — auth never reached ttyd, ttyd never
started streaming, resulting in permanent black screen and reconnect loop.

Fix: use receive() and dispatch on message type, handling both bytes and text.
2026-03-28 08:50:43 -07:00
Brian Krabach 6cf54efcb5 docs: use uv pip install in development setup instructions 2026-03-28 06:52:39 -07:00
Brian Krabach 82ec323aaa fix: address pre-merge review findings — stale identity strings, pyright error, fragile test path, formatting, README accuracy
Required fixes:
- main.py: update module docstring, startup comment, and FastAPI title from
  'tmux-web coordinator' / 'coordinator service' → 'muxplex' (4 locations)
- test_api.py: replace hasattr/BaseRoute pattern with isinstance(r, (APIRoute,
  APIWebSocketRoute)) to satisfy pyright (union-attr error eliminated)

Recommendations applied:
- test_api.py + test_cli.py: auto-formatted with ruff (blank-line normalisation)
- test_cli.py: replace exec(Path('muxplex/__main__.py').read_text()) with
  importlib.util.find_spec() to obtain the absolute path — no longer assumes
  pytest is invoked from a specific working directory
- README.md: fix architecture table — WebSocket proxy moved from ttyd.py
  description to main.py; ttyd.py now correctly described as lifecycle only

All 178 tests pass; python_check clean (0 errors, 0 warnings).
2026-03-28 02:47:18 -07:00
Brian Krabach 110a503df0 docs: fix JS test command and add terminal.js to architecture tree 2026-03-28 02:38:28 -07:00
Brian Krabach 18365e246c docs: rewrite README with uvx/uv install instructions and full usage guide 2026-03-28 02:34:11 -07:00
Brian Krabach 23d1d9c113 chore: retire requirements.txt in favor of pyproject.toml
Addresses code quality suggestion from review: requirements.txt was a
redundant secondary source of truth. Dependencies are now exclusively
managed in pyproject.toml with proper runtime/dev separation.

- requirements.txt: replaced with a comment redirecting to pip install -e '[dev]'
- README.md: updated install command from pip install -r requirements.txt
  to pip install -e '[dev]'
2026-03-28 02:31:15 -07:00
Brian Krabach 10906ec4b0 chore: configure pyproject.toml for distribution (entry point, deps, hatchling build) 2026-03-28 02:26:07 -07:00
Brian Krabach c2087083c2 feat: add CLI entry point (muxplex serve, muxplex install-service) and __main__.py 2026-03-28 02:18:58 -07:00
Brian Krabach a70e8f6f52 feat: add WebSocket proxy route to replace Caddy, update bell hook port to configurable SERVER_PORT 2026-03-28 02:11:24 -07:00
Brian Krabach 74b63033d7 refactor: rename coordinator → muxplex package, move frontend inside as package data 2026-03-28 02:02:50 -07:00
Brian Krabach be26d40a31 docs: add distribution packaging implementation plan 2026-03-28 01:57:30 -07:00
Brian Krabach cb38f11588 refactor: replace var with const and fix misleading closure comment in terminal.js 2026-03-28 01:32:45 -07:00