mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 20:40:13 +03:00
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.
300 lines
12 KiB
Python
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
|