feat(compute): add SPICE WebSocket console support

Add WebSocket-based console support for SPICE protocol, enabling
   browser-based graphical console access with enhanced features
   like clipboard sharing, USB redirection, and audio streaming.

   Changes:
   - Add start_spice_websocket_console() method in BaseNode for SPICE
     WebSocket to TCP bridging with bidirectional binary forwarding
   - Add /console/spice WebSocket endpoints in QEMU and Docker compute APIs
   - Add /console/spice WebSocket proxy endpoint in controller API
   - Add comprehensive API documentation in docs/features/

   Supported console types:
   - spice: Basic SPICE protocol support
   - spice+agent: SPICE with spice-vdagent for enhanced features

   Architecture:
   - Browser WebSocket → Controller (JWT + RBAC) → Compute (Basic Auth)
   - No external websockify processes required
   - Consistent with existing VNC WebSocket implementation
This commit is contained in:
YueGuobin committed 2026-03-16 22:21:25 +08:00
1 parent ba92b405da
commit c98f802362
5 files changed
+1046

No files matched your search

@@ -399,6 +399,21 @@ async def vnc_console_ws(
await node.start_vnc_websocket_console(websocket)
@router.websocket(
"/{node_id}/console/spice"
)
async def spice_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: DockerVM = Depends(dep_node)
) -> None:
"""
SPICE Console WebSocket.
"""
if websocket:
await node.start_spice_websocket_console(websocket)
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
@@ -430,6 +430,21 @@ async def vnc_console_ws(
await node.start_vnc_websocket_console(websocket)
@router.websocket(
"/{node_id}/console/spice"
)
async def spice_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: QemuVM = Depends(dep_node)
) -> None:
"""
SPICE Console WebSocket.
"""
if websocket:
await node.start_spice_websocket_console(websocket)
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
+75
View File
@@ -721,6 +721,81 @@ async def vnc_console(
except aiohttp.ClientError as e:
log.error(f"Client error received when forwarding to compute VNC console WebSocket: {e}")
@router.websocket("/{node_id}/console/spice")
async def spice_console(
websocket: WebSocket,
current_user: schemas.User = Depends(has_privilege_on_websocket("Node.Console")),
node: Node = Depends(dep_node)
) -> None:
"""
SPICE WebSocket console.
Required privilege: Node.Console
"""
if current_user is None:
return
compute = node.compute
log.info(
f"New client {websocket.client.host}:{websocket.client.port} has connected to controller SPICE console WebSocket"
)
compute_host = compute.host
try:
# handle IPv6 address
ip = ipaddress.ip_address(compute_host)
if isinstance(ip, ipaddress.IPv6Address):
compute_host = '[' + compute_host + ']'
except ValueError:
pass
spice_console_compute_url = (
f"{websocket.url.scheme}://{compute_host}:{compute.port}/v3/compute/projects/"
f"{node.project.id}/{node.node_type}/nodes/{node.id}/console/spice"
)
async def spice_receive(spice_console_compute):
"""
Receive binary WebSocket data from client and forward to compute SPICE console WebSocket.
"""
try:
while True:
data = await websocket.receive_bytes()
if data:
await spice_console_compute.send_bytes(data)
except WebSocketDisconnect:
await spice_console_compute.close()
log.info(
f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller"
f" SPICE console WebSocket"
)
try:
# receive binary data from compute SPICE console WebSocket and forward to client
log.info(f"Forwarding SPICE console WebSocket to '{spice_console_compute_url}'")
server_config = Config.instance().settings.Server
user = server_config.compute_username
password = server_config.compute_password
if not user:
raise ControllerForbiddenError("Compute username is not set")
user = user.strip()
if user and password:
auth = aiohttp.BasicAuth(user, password.get_secret_value(), "utf-8")
else:
auth = aiohttp.BasicAuth(user, "")
ssl_context = Controller.instance().ssl_context()
async with HTTPClient.get_client().ws_connect(spice_console_compute_url, auth=auth, ssl_context=ssl_context) as ws:
asyncio.ensure_future(spice_receive(ws))
async for msg in ws:
if msg.type == aiohttp.WSMsgType.BINARY:
await websocket.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
break
except aiohttp.ClientError as e:
log.error(f"Client error received when forwarding to compute SPICE console WebSocket: {e}")
@router.post(
"/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
+70
View File
@@ -611,6 +611,76 @@ class BaseNode:
vnc_writer.close()
await vnc_writer.wait_closed()
async def start_spice_websocket_console(self, websocket):
"""
Connect to SPICE console using WebSocket.
:param websocket: FastAPI WebSocket object
"""
log.info(
f"New client {websocket.client.host}:{websocket.client.port} has connected to compute "
f"SPICE console WebSocket"
)
if self.status != "started":
await websocket.close(code=1000)
raise NodeError(f"Node {self.name} is not started")
if self._console_type not in ("spice", "spice+agent"):
await websocket.close(code=1000)
raise NodeError(f"Node {self.name} console type is not SPICE")
try:
spice_reader, spice_writer = await asyncio.open_connection(
self._manager.port_manager.console_host,
self.console # SPICE port
)
log.info(f"Connected to SPICE server {self._manager.port_manager.console_host}:{self.console}")
except ConnectionError as e:
await websocket.close(code=1000)
raise NodeError(f"Cannot connect to node {self.name} SPICE server: {e}")
async def ws_forward(spice_writer):
# Browser → SPICE: Forward binary WebSocket data to SPICE server
try:
while True:
data = await websocket.receive_bytes()
if data:
spice_writer.write(data)
await spice_writer.drain()
except WebSocketDisconnect:
log.info(
f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute "
f"SPICE console WebSocket"
)
async def spice_forward(spice_reader):
# SPICE → Browser: Forward SPICE data to WebSocket
try:
while not spice_reader.at_eof():
data = await spice_reader.read(65536) # Larger buffer for SPICE data
if data:
await websocket.send_bytes(data)
except Exception as e:
log.warning(f"Exception while forwarding SPICE data to WebSocket: {e}")
# Keep forwarding WebSocket data in both directions
if sys.version_info >= (3, 11, 0):
# Starting with Python 3.11, passing coroutine objects to wait() directly is forbidden.
aws = [asyncio.create_task(ws_forward(spice_writer)), asyncio.create_task(spice_forward(spice_reader))]
else:
aws = [ws_forward(spice_writer), spice_forward(spice_reader)]
done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED)
for task in done:
if task.exception():
log.warning(f"Exception while forwarding WebSocket data to SPICE server: {task.exception()}")
for task in pending:
task.cancel()
spice_writer.close()
await spice_writer.wait_closed()
@property
def aux(self):
"""