fix: route terminal WS through Python proxy to work behind reverse proxy

The browser was connecting directly to muxterm at ws://host:7682/ws which
fails when accessed through a reverse proxy (Caddy/Tailscale at
muxplex.ampbox.io) because:
  1. Port 7682 is never exposed externally — only port 8088 is proxied
  2. ws:// is blocked by browsers on https:// pages (mixed-content policy)

Fix (two parts):

main.py — Add /terminal/ws WebSocket route that:
  - Verifies the session cookie (same pattern as federation WS proxy)
  - Opens a connection to ws://127.0.0.1:MUXTERM_PORT/ws?token=...
  - Relays frames bidirectionally (text + binary) between browser and muxterm

terminal.js — Change _openMuxtermSocket() from:
  ws://location.hostname:port/ws?token=...   ← direct port, ws:// hardcoded
to:
  wss://location.host/terminal/ws?token=...  ← same host/port as app, wss:// on HTTPS

Auth remains two-layered: session cookie at Python level, HMAC token at
muxterm level. The proxy is thin — just auth + frame relay, zero session logic.

test_ws_proxy.py — New tests for auth rejection (4001), proxy URL construction
(token passed through), and frame relay.
test_frontend_js.py — Update the terminal.js route assertion to match new path.
This commit is contained in:
Ken
2026-05-28 08:23:22 +00:00
parent c3b0502297
commit 71f371fe85
4 changed files with 261 additions and 13 deletions
+6 -2
View File
@@ -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;
+68
View File
@@ -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.
+15 -11
View File
@@ -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"
)
+172
View File
@@ -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=<value> 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}"
)