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:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
Reference in New Issue
Block a user