gns3-server/gns3server/utils/websocket_to_websocket.py
YueGuobin 868e317d24
fix: close DockerHTTPClient session to prevent UnixConnector leak in Web Wireshark
The Web Wireshark WebSocket endpoint created a WebWiresharkManager but
never called close(), leaving the DockerHTTPClient's ClientSession with
UnixConnector unclosed when users closed the browser tab.

Also switch asyncio.wait in WebSocket proxy from ALL_COMPLETED to
FIRST_COMPLETED to avoid blocking cleanup when one direction disconnects.
2026-05-18 23:59:56 +08:00

300 lines
12 KiB
Python

#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
WebSocket-to-WebSocket proxy utility functions.
Similar pattern to VNC console implementation in base_node.py:
- Bidirectional forwarding
- Binary data only (for protocols like xpra, VNC, RDP)
- Graceful error handling
"""
import asyncio
import logging
import sys
from typing import Optional
import aiohttp
from fastapi import WebSocket, status
from fastapi.websockets import WebSocketDisconnect
from starlette.websockets import WebSocket as StarletteWebSocket
log = logging.getLogger(__name__)
async def websocket_proxy(
client_ws: WebSocket,
target_url: str,
requested_protocols: list = None,
buffer_size: int = 65536,
timeout: Optional[float] = None
) -> None:
"""
Proxy binary WebSocket data between client and target WebSocket server.
Designed for binary protocols like xpra, VNC, RDP.
Text data is not supported.
Args:
client_ws: Client WebSocket connection (FastAPI)
target_url: Target WebSocket URL to proxy to
requested_protocols: List of subprotocols requested by client (default: ["binary"])
buffer_size: Buffer size for binary data (default: 65536)
timeout: Connection timeout in seconds (default: None)
Raises:
aiohttp.ClientError: If connection to target fails
"""
client_info = f"{client_ws.client.host}:{client_ws.client.port}"
async def forward_client_to_target(target_ws):
"""Client → Target: Forward binary WebSocket data."""
try:
while True:
data = await client_ws.receive_bytes()
if data:
await target_ws.send_bytes(data)
except WebSocketDisconnect:
log.info(f"Client {client_info} disconnected from WebSocket proxy")
except Exception as e:
log.warning(f"Error forwarding client to target: {e}")
async def forward_target_to_client(target_ws):
"""Target → Client: Forward binary WebSocket data."""
try:
async for msg in target_ws:
if msg.type == aiohttp.WSMsgType.BINARY:
try:
await client_ws.send_bytes(msg.data)
except Exception as e:
log.debug(f"Failed to send to client (possibly disconnected): {e}")
break
elif msg.type == aiohttp.WSMsgType.ERROR:
log.warning(f"Target WebSocket error: {msg.data}")
break
elif msg.type == aiohttp.WSMsgType.CLOSE:
log.info("Target WebSocket closed")
# Don't try to forward close to client if already disconnected
break
except Exception as e:
log.warning(f"Error forwarding target to client: {e}")
try:
# Connect to target WebSocket FIRST to negotiate subprotocol
log.info(f"Connecting to target WebSocket: {target_url}")
# Use requested protocols from client (usually "binary" for xpra)
subprotocols = requested_protocols or ["binary"]
log.info(f"Client requested subprotocols: {subprotocols}")
timeout_config = {}
if timeout:
timeout_config = {"timeout": aiohttp.ClientTimeout(total=timeout)}
async with aiohttp.ClientSession() as session:
async with session.ws_connect(target_url, protocols=subprotocols, **timeout_config) as target_ws:
negotiated_protocol = target_ws.protocol
log.info(f"Target WebSocket negotiated protocol: {negotiated_protocol}")
log.info(f"WebSocket proxy established: {client_info}{target_url}")
# Run both forwarding tasks in parallel
# Similar pattern to base_node.py VNC implementation
if sys.version_info >= (3, 11, 0):
aws = [
asyncio.create_task(forward_client_to_target(target_ws)),
asyncio.create_task(forward_target_to_client(target_ws))
]
else:
aws = [
forward_client_to_target(target_ws),
forward_target_to_client(target_ws)
]
try:
done, pending = await asyncio.wait(
aws,
return_when=asyncio.FIRST_COMPLETED
)
except Exception as e:
log.error(f"asyncio.wait raised exception: {e}")
# Check for exceptions
for task in done:
if task.exception():
log.warning(
f"WebSocket proxy task exception: {task.exception()}"
)
# Cancel pending tasks
for task in pending:
task.cancel()
except aiohttp.ClientError as e:
log.error(f"WebSocket proxy connection error: {e}")
await client_ws.close(
code=status.WS_1011_INTERNAL_ERROR,
reason=f"Proxy connection failed: {e}"
)
except Exception as e:
log.error(f"WebSocket proxy unexpected error: {e}")
await client_ws.close(
code=status.WS_1011_INTERNAL_ERROR,
reason=str(e)
)
async def websocket_proxy_with_manual_accept(
client_ws: StarletteWebSocket,
target_url: str,
requested_protocols: list = None,
buffer_size: int = 65536,
timeout: Optional[float] = None
) -> None:
"""
Proxy binary WebSocket data between client and target WebSocket server.
This version manually accepts the client WebSocket connection after negotiating
the subprotocol with the backend server. This is required for protocols like xpra
that depend on proper subprotocol negotiation.
Args:
client_ws: Client WebSocket connection (Starlette WebSocket)
target_url: Target WebSocket URL to proxy to
requested_protocols: List of subprotocols requested by client (default: ["binary"])
buffer_size: Buffer size for binary data (default: 65536)
timeout: Connection timeout in seconds (default: None)
Raises:
aiohttp.ClientError: If connection to target fails
"""
client_info = f"{client_ws.client.host}:{client_ws.client.port}" if hasattr(client_ws, 'client') else "unknown"
async def forward_client_to_target(target_ws):
"""Client → Target: Forward binary WebSocket data."""
log.info("forward_client_to_target: started")
try:
while True:
log.info("forward_client_to_target: waiting for client data")
data = await client_ws.receive_bytes()
log.info(f"forward_client_to_target: received {len(data)} bytes from client")
if data:
await target_ws.send_bytes(data)
except Exception as e:
log.info(f"Client disconnected or error: {e}")
raise
async def forward_target_to_client(target_ws):
"""Target → Client: Forward binary WebSocket data."""
log.info("forward_target_to_client: started, about to iterate")
try:
async for msg in target_ws:
log.info(f"forward_target_to_client: got message type={msg.type}")
if msg.type == aiohttp.WSMsgType.BINARY:
try:
await client_ws.send_bytes(msg.data)
except Exception as e:
log.debug(f"Failed to send to client (possibly disconnected): {e}")
break
elif msg.type == aiohttp.WSMsgType.ERROR:
log.warning(f"Target WebSocket error: {msg.data}")
break
elif msg.type == aiohttp.WSMsgType.CLOSE:
log.info("Target WebSocket closed")
break
log.info("forward_target_to_client: iteration ended")
except Exception as e:
log.warning(f"Error forwarding target to client: {e}")
try:
# Connect to target WebSocket FIRST to negotiate subprotocol
log.info(f"Connecting to target WebSocket: {target_url}")
# Use requested protocols from client (usually "binary" for xpra)
subprotocols = requested_protocols or ["binary"]
log.info(f"Client requested subprotocols: {subprotocols}")
timeout_config = {}
if timeout:
timeout_config = {"timeout": aiohttp.ClientTimeout(total=timeout)}
async with aiohttp.ClientSession() as session:
log.info(f"About to ws_connect to {target_url}")
ws_conn = session.ws_connect(target_url, protocols=subprotocols, **timeout_config)
log.info("ws_connect coroutine created, about to enter")
async with ws_conn as target_ws:
negotiated_protocol = target_ws.protocol
log.info(f"Target WebSocket negotiated protocol: {negotiated_protocol}")
log.info(f"target_ws.closed = {target_ws.closed}")
# Accept client WebSocket with the negotiated subprotocol
# This is CRITICAL for xpra to work properly
log.info(f"Accepting client WebSocket with subprotocol: {negotiated_protocol}")
await client_ws.accept(subprotocol=negotiated_protocol)
log.info("Client WebSocket accepted")
log.info(f"WebSocket proxy established: {client_info}{target_url}")
# Run both forwarding tasks in parallel
if sys.version_info >= (3, 11, 0):
aws = [
asyncio.create_task(forward_client_to_target(target_ws)),
asyncio.create_task(forward_target_to_client(target_ws))
]
else:
aws = [
forward_client_to_target(target_ws),
forward_target_to_client(target_ws)
]
log.info(f"About to call asyncio.wait with {len(aws)} tasks")
try:
done, pending = await asyncio.wait(
aws,
return_when=asyncio.FIRST_COMPLETED
)
log.info(f"asyncio.wait returned. done={len(done)}, pending={len(pending)}")
except Exception as e:
log.error(f"asyncio.wait raised exception: {e}")
log.info("After asyncio.wait check")
# Check for exceptions
for task in done:
if task.exception():
log.warning(
f"WebSocket proxy task exception: {task.exception()}"
)
# Cancel pending tasks
for task in pending:
log.info("Cancelling pending task")
task.cancel()
log.info("Cleanup complete, exiting websocket_proxy_with_manual_accept")
except aiohttp.ClientError as e:
log.error(f"WebSocket proxy connection error: {e}")
try:
await client_ws.close(code=status.WS_1011_INTERNAL_ERROR)
except Exception:
pass
except Exception as e:
log.error(f"WebSocket proxy unexpected error: {e}")
try:
await client_ws.close(code=status.WS_1011_INTERNAL_ERROR)
except Exception:
pass