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.
|
* Sets binaryType='arraybuffer'. Handles open, message, close events.
|
||||||
*/
|
*/
|
||||||
function _openMuxtermSocket() {
|
function _openMuxtermSocket() {
|
||||||
var reconnectOverlay = document.getElementById('reconnect-overlay');
|
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);
|
var ws = new WebSocket(url);
|
||||||
_ws = ws;
|
_ws = ws;
|
||||||
|
|||||||
@@ -964,6 +964,74 @@ async def get_terminal_token() -> dict:
|
|||||||
return {"token": _generate_muxterm_token(), "port": MUXTERM_PORT}
|
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")
|
@app.get("/api/instance-info")
|
||||||
async def instance_info() -> dict:
|
async def instance_info() -> dict:
|
||||||
"""Return this instance's display name, device identity, and version.
|
"""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:
|
def test_terminal_js_connects_via_proxy_path() -> None:
|
||||||
"""connectWebSocket URL must target muxterm port directly, not /terminal/ws.
|
"""connectWebSocket URL must use /terminal/ws on the main app server.
|
||||||
|
|
||||||
The old ttyd proxy routed through /terminal/ws on the main server.
|
The old direct-port approach (ws://host:7682/ws) does not work when
|
||||||
The muxterm protocol connects directly to the muxterm port:
|
muxplex is behind a reverse proxy (Caddy, Tailscale, nginx) because port
|
||||||
ws://host:muxtermPort/ws?token=...
|
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, (
|
assert "/terminal/ws" in _TERMINAL_JS, (
|
||||||
"terminal.js must not connect to /terminal/ws — "
|
"terminal.js must connect to /terminal/ws — "
|
||||||
"muxterm connects directly to ws://host:muxtermPort/ws"
|
"direct muxterm port is not accessible behind a reverse proxy"
|
||||||
)
|
)
|
||||||
assert "_muxtermPort" in _TERMINAL_JS, (
|
assert "location.host" in _TERMINAL_JS, (
|
||||||
"terminal.js must reference _muxtermPort — "
|
"terminal.js must use location.host (includes port) for the WS URL"
|
||||||
"WebSocket connects directly to the muxterm port"
|
)
|
||||||
|
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