diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 0360129..30bb833 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -67,12 +67,16 @@ function connectWebSocket() { } /** - * Open a WebSocket directly to muxterm on ws://host:muxtermPort/ws?token=... + * Open a WebSocket to muxterm via the Python proxy at /terminal/ws?token=... + * The proxy runs on the same host/port/protocol as the main app, so it works + * behind any reverse proxy (Caddy, Tailscale, nginx) without exposing muxterm + * directly to the network or triggering mixed-content errors on HTTPS pages. * Sets binaryType='arraybuffer'. Handles open, message, close events. */ function _openMuxtermSocket() { var reconnectOverlay = document.getElementById('reconnect-overlay'); - var url = 'ws://' + location.hostname + ':' + _muxtermPort + '/ws?token=' + encodeURIComponent(_muxtermToken); + var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + var url = proto + '//' + location.host + '/terminal/ws?token=' + encodeURIComponent(_muxtermToken); var ws = new WebSocket(url); _ws = ws; diff --git a/muxplex/main.py b/muxplex/main.py index 5a32878..872a7ee 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -964,6 +964,74 @@ async def get_terminal_token() -> dict: return {"token": _generate_muxterm_token(), "port": MUXTERM_PORT} +@app.websocket("/terminal/ws") +async def terminal_ws_proxy(websocket: WebSocket) -> None: + """Proxy WebSocket frames between the browser and the local muxterm process. + + Allows browsers behind a reverse proxy (Caddy, Tailscale, nginx) to reach + muxterm through the main app port instead of muxterm's internal port (7682), + which is never exposed externally. Also ensures the connection uses the + correct protocol (wss:// on HTTPS sites, avoiding mixed-content errors). + + Auth is two-layered: + 1. Session cookie is verified here (same check as federation WS proxy). + 2. The HMAC token in the query string is verified by muxterm. + + Closes with code 4001 if the session cookie is missing or invalid. + Closes with code 1011 (internal error) if muxterm is not reachable. + """ + # Session-cookie auth — BaseHTTPMiddleware does not cover WebSocket scope. + host = websocket.client.host if websocket.client else "" + if host not in ("127.0.0.1", "::1"): + session_cookie = websocket.cookies.get("muxplex_session") + cookie_ok = session_cookie and verify_session_cookie( + _auth_secret, session_cookie, _auth_ttl + ) + if not cookie_ok: + await websocket.close(code=4001) + return + + token = websocket.query_params.get("token", "") + muxterm_url = f"ws://127.0.0.1:{MUXTERM_PORT}/ws" + if token: + muxterm_url += f"?token={token}" + + await websocket.accept() + + try: + async with websockets.connect(muxterm_url) as muxterm_ws: + + async def client_to_muxterm() -> None: + try: + while True: + msg = await websocket.receive() + if msg.get("bytes"): + await muxterm_ws.send(msg["bytes"]) + elif msg.get("text"): + await muxterm_ws.send(msg["text"]) + except Exception as exc: + _log.debug("terminal ws relay closed (client\u2192muxterm): %s", exc) + + async def muxterm_to_client() -> None: + try: + async for message in muxterm_ws: + if isinstance(message, bytes): + await websocket.send_bytes(message) + else: + await websocket.send_text(message) + except Exception as exc: + _log.debug("terminal ws relay closed (muxterm\u2192client): %s", exc) + + await asyncio.gather(client_to_muxterm(), muxterm_to_client()) + except Exception as exc: + _log.debug("terminal ws proxy closed: %s", exc) + finally: + try: + await websocket.close() + except Exception: + pass + + @app.get("/api/instance-info") async def instance_info() -> dict: """Return this instance's display name, device identity, and version. diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 6ccb5f2..74a1a9c 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -4990,20 +4990,24 @@ def test_terminal_js_fetches_terminal_token() -> None: ) -def test_terminal_js_connects_to_muxterm_port_directly() -> None: - """connectWebSocket URL must target muxterm port directly, not /terminal/ws. +def test_terminal_js_connects_via_proxy_path() -> None: + """connectWebSocket URL must use /terminal/ws on the main app server. - The old ttyd proxy routed through /terminal/ws on the main server. - The muxterm protocol connects directly to the muxterm port: - ws://host:muxtermPort/ws?token=... + The old direct-port approach (ws://host:7682/ws) does not work when + muxplex is behind a reverse proxy (Caddy, Tailscale, nginx) because port + 7682 is never exposed externally and ws:// is blocked on HTTPS pages. + The fix routes through Python's /terminal/ws endpoint which proxies to + muxterm internally, using the same host/port/protocol as the main app. """ - assert "/terminal/ws" not in _TERMINAL_JS, ( - "terminal.js must not connect to /terminal/ws — " - "muxterm connects directly to ws://host:muxtermPort/ws" + assert "/terminal/ws" in _TERMINAL_JS, ( + "terminal.js must connect to /terminal/ws — " + "direct muxterm port is not accessible behind a reverse proxy" ) - assert "_muxtermPort" in _TERMINAL_JS, ( - "terminal.js must reference _muxtermPort — " - "WebSocket connects directly to the muxterm port" + assert "location.host" in _TERMINAL_JS, ( + "terminal.js must use location.host (includes port) for the WS URL" + ) + assert "wss:" in _TERMINAL_JS, ( + "terminal.js must use wss: on HTTPS pages to avoid mixed-content errors" ) diff --git a/muxplex/tests/test_ws_proxy.py b/muxplex/tests/test_ws_proxy.py new file mode 100644 index 0000000..7ac30ee --- /dev/null +++ b/muxplex/tests/test_ws_proxy.py @@ -0,0 +1,172 @@ +""" +Tests for the /terminal/ws WebSocket proxy endpoint. + +The proxy forwards frames between the browser and the local muxterm process, +allowing access through a reverse proxy (Caddy, Tailscale, nginx) without +exposing muxterm's port directly. Auth is done via session cookie; the HMAC +token is passed through to muxterm verbatim. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from muxplex.main import app + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def patch_startup_and_state(tmp_path, monkeypatch): + """Redirect state files, mock muxterm start/stop, no-op poll loop.""" + tmp_state_dir = tmp_path / "state" + tmp_state_path = tmp_state_dir / "state.json" + monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir) + monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path) + + monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock()) + monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock()) + + async def noop_poll_loop() -> None: + pass + + monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop) + + +@pytest.fixture(autouse=True) +def reset_federation_cache(): + """Clear _federation_cache before and after each test.""" + import muxplex.main as main_mod + + main_mod._federation_cache.clear() + yield + main_mod._federation_cache.clear() + + +@pytest.fixture +def client(monkeypatch): + """Authenticated TestClient with a valid session cookie.""" + monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password") + with TestClient(app) as c: + from muxplex.auth import create_session_cookie + from muxplex.main import _auth_secret, _auth_ttl + + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + yield c + + +@pytest.fixture +def unauthed_client(monkeypatch): + """TestClient WITHOUT a session cookie — used to test auth rejection.""" + monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password") + with TestClient(app) as c: + yield c + + +# --------------------------------------------------------------------------- +# Auth rejection test +# --------------------------------------------------------------------------- + + +def test_terminal_ws_proxy_rejects_unauthenticated(unauthed_client): + """Unauthenticated WebSocket connection must be closed with code 4001. + + The proxy checks the session cookie before forwarding to muxterm. + Without a valid cookie the connection is rejected immediately. + """ + with pytest.raises((WebSocketDisconnect, Exception)): + with unauthed_client.websocket_connect("/terminal/ws?token=test") as ws: + ws.receive_text() + + +# --------------------------------------------------------------------------- +# Proxy forwarding test (muxterm mocked) +# --------------------------------------------------------------------------- + + +def test_terminal_ws_proxy_relays_text_to_muxterm(client, monkeypatch): + """Authenticated connection: text frames sent to /terminal/ws reach muxterm. + + Mocks websockets.connect so no real muxterm is needed. Verifies the + proxy opens a connection to ws://127.0.0.1:MUXTERM_PORT/ws?token=... + and relays messages in both directions. + """ + from muxplex.main import MUXTERM_PORT + + # Build a mock muxterm WebSocket that yields one text message then stops + mock_muxterm_ws = AsyncMock() + mock_muxterm_ws.__aenter__ = AsyncMock(return_value=mock_muxterm_ws) + mock_muxterm_ws.__aexit__ = AsyncMock(return_value=False) + + # __aiter__ yields one text message then StopAsyncIteration + messages = [b"hello from muxterm"] + + async def fake_aiter(): + for m in messages: + yield m + + mock_muxterm_ws.__aiter__ = lambda self: fake_aiter() + mock_muxterm_ws.send = AsyncMock() + + connected_url = [] + + def mock_connect(url, **kwargs): + connected_url.append(url) + return mock_muxterm_ws + + with patch("muxplex.main.websockets.connect", side_effect=mock_connect): + try: + with client.websocket_connect("/terminal/ws?token=mytoken") as ws: + # Receive the relayed bytes from muxterm + data = ws.receive_bytes() + assert data == b"hello from muxterm" + except Exception: + pass # normal close after muxterm exhausted + + # Verify the proxy connected to the correct muxterm URL + assert len(connected_url) == 1 + assert f"ws://127.0.0.1:{MUXTERM_PORT}/ws?token=mytoken" == connected_url[0] + + +def test_terminal_ws_proxy_url_includes_token(client, monkeypatch): + """The proxy must append ?token= to the muxterm WebSocket URL. + + muxterm validates the HMAC token on each new connection; omitting it + would cause muxterm to reject the connection. + """ + from muxplex.main import MUXTERM_PORT + + mock_muxterm_ws = AsyncMock() + mock_muxterm_ws.__aenter__ = AsyncMock(return_value=mock_muxterm_ws) + mock_muxterm_ws.__aexit__ = AsyncMock(return_value=False) + + async def fake_aiter(): + return + yield # make it an async generator + + mock_muxterm_ws.__aiter__ = lambda self: fake_aiter() + mock_muxterm_ws.send = AsyncMock() + + connected_url = [] + + def mock_connect(url, **kwargs): + connected_url.append(url) + return mock_muxterm_ws + + token = "abc123" + with patch("muxplex.main.websockets.connect", side_effect=mock_connect): + try: + with client.websocket_connect(f"/terminal/ws?token={token}") as ws: + pass + except Exception: + pass + + assert any(f"token={token}" in url for url in connected_url), ( + f"Expected token={token!r} in muxterm URL, got: {connected_url}" + )