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
24 KiB
VNC/SPICE WebSocket Console Support
Feature Overview
Date: 2026-03-16
Status: Implemented
Branch: feature/vnc-interface
Component: Compute API, Controller API, BaseNode
Summary
Add WebSocket-based console support for VNC and SPICE console types, enabling browser-based graphical console access to QEMU VMs and Docker containers without requiring standalone websockify processes.
Architecture
Design Principles
- Unified WebSocket Architecture: All console types (Telnet, VNC, SPICE) use consistent WebSocket endpoints
- Authentication Integration: Leverage existing GNS3 authentication mechanisms
- No External Dependencies: Eliminate standalone websockify processes and additional port allocations
- Layered Design: Controller → Compute → Node WebSocket forwarding
Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐
│ Browser Client │
│ - noVNC for VNC │
│ - spice-html5 for SPICE │
└────────────────────────┬────────────────────────────────────────┘
│ WebSocket Connection
▼
┌─────────────────────────────────────────────────────────────────┐
│ GNS3 Controller API │
│ - /v3/controller/projects/{id}/qemu/nodes/{id}/console/vnc │
│ - /v3/controller/projects/{id}/qemu/nodes/{id}/console/spice │
│ - HTTP Basic Auth + RBAC (Node.Console privilege) │
└────────────────────────┬────────────────────────────────────────┘
│ WebSocket Forwarding
▼
┌─────────────────────────────────────────────────────────────────┐
│ GNS3 Compute API │
│ - /v3/compute/projects/{id}/qemu/nodes/{id}/console/vnc │
│ - /v3/compute/projects/{id}/qemu/nodes/{id}/console/spice │
│ - HTTP Basic Auth (compute credentials) │
└────────────────────────┬────────────────────────────────────────┘
│ WebSocket → TCP Bridge
▼
┌─────────────────────────────────────────────────────────────────┐
│ BaseNode.start_vnc_websocket_console() │
│ BaseNode.start_spice_websocket_console() │
│ - Bidirectional binary data forwarding │
│ - WebSocket → TCP (VNC:5900+, SPICE:5900+) │
└────────────────────────┬────────────────────────────────────────┘
│ TCP Connection
▼
┌─────────────────────────────────────────────────────────────────┐
│ QEMU VM / Docker Container │
│ - VNC Server (QXL display) │
│ - SPICE Server (with optional spice-vdagent) │
└─────────────────────────────────────────────────────────────────┘
API Endpoints
Compute Layer
VNC Console WebSocket
WS /v3/compute/projects/{project_id}/qemu/nodes/{node_id}/console/vnc
WS /v3/compute/projects/{project_id}/docker/nodes/{node_id}/console/vnc
Authentication: HTTP Basic Auth (compute credentials)
Request Headers:
Upgrade: websocket
Connection: Upgrade
Authorization: Basic <base64(username:password)>
Response:
- Binary WebSocket connection for VNC protocol (RFB)
Implementation:
# gns3server/api/routes/compute/qemu_nodes.py
@router.websocket("/{node_id}/console/vnc")
async def vnc_console_ws(
websocket: WebSocket = Depends(ws_compute_authentication),
node: QemuVM = Depends(dep_node)
):
await node.start_vnc_websocket_console(websocket)
SPICE Console WebSocket
WS /v3/compute/projects/{project_id}/qemu/nodes/{node_id}/console/spice
WS /v3/compute/projects/{project_id}/docker/nodes/{node_id}/console/spice
Authentication: HTTP Basic Auth (compute credentials)
Request Headers:
Upgrade: websocket
Connection: Upgrade
Authorization: Basic <base64(username:password)>
Response:
- Binary WebSocket connection for SPICE protocol
Implementation:
# gns3server/api/routes/compute/qemu_nodes.py
@router.websocket("/{node_id}/console/spice")
async def spice_console_ws(
websocket: WebSocket = Depends(ws_compute_authentication),
node: QemuVM = Depends(dep_node)
):
await node.start_spice_websocket_console(websocket)
Controller Layer
VNC Console WebSocket
WS /v3/controller/projects/{project_id}/qemu/nodes/{node_id}/console/vnc
WS /v3/controller/projects/{project_id}/docker/nodes/{node_id}/console/vnc
Authentication: User JWT token + RBAC privilege check
Required Privilege: Node.Console
Response:
- Binary WebSocket connection (proxied to compute layer)
SPICE Console WebSocket
WS /v3/controller/projects/{project_id}/qemu/nodes/{node_id}/console/spice
WS /v3/controller/projects/{project_id}/docker/nodes/{node_id}/console/spice
Authentication: User JWT token + RBAC privilege check
Required Privilege: Node.Console
Response:
- Binary WebSocket connection (proxied to compute layer)
Console Types
Supported Console Types
| Console Type | Description | Node Support |
|---|---|---|
telnet |
Serial console via Telnet protocol | All nodes |
vnc |
Graphical console via VNC (RFB) protocol | QEMU, Docker |
spice |
Enhanced graphical console via SPICE protocol | QEMU only* |
spice+agent |
SPICE with spice-vdagent for features like clipboard sharing, folder sharing | QEMU only* |
none |
No console | All nodes |
*Docker supports the console type in schema but typically doesn't use SPICE (SPICE is designed for KVM/QEMU VMs).
Console Type Configuration
QEMU VM:
{
"console_type": "spice+agent",
"console": 5900
}
Docker Container:
{
"console_type": "vnc",
"console": 5900
}
Implementation Details
BaseNode WebSocket Forwarding
File: gns3server/compute/base_node.py
VNC Console Implementation
async def start_vnc_websocket_console(self, websocket):
"""
Connect to VNC console using WebSocket.
:param websocket: FastAPI WebSocket object
"""
# 1. Validation
if self.status != "started":
await websocket.close(code=1000)
raise NodeError(f"Node {self.name} is not started")
if self._console_type != "vnc":
await websocket.close(code=1000)
raise NodeError(f"Node {self.name} console type is not vnc")
# 2. Connect to VNC server
vnc_reader, vnc_writer = await asyncio.open_connection(
self._manager.port_manager.console_host,
self.console # VNC TCP port (e.g., 5900)
)
# 3. Bidirectional forwarding
async def ws_forward(vnc_writer):
# Browser → VNC
while True:
data = await websocket.receive_bytes()
vnc_writer.write(data)
await vnc_writer.drain()
async def vnc_forward(vnc_reader):
# VNC → Browser
while not vnc_reader.at_eof():
data = await vnc_reader.read(65536) # Larger buffer for VNC frames
await websocket.send_bytes(data)
# 4. Run both directions concurrently
aws = [
asyncio.create_task(ws_forward(vnc_writer)),
asyncio.create_task(vnc_forward(vnc_reader))
]
done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED)
# 5. Cleanup
for task in pending:
task.cancel()
vnc_writer.close()
await vnc_writer.wait_closed()
SPICE Console Implementation
SPICE implementation is identical to VNC, except for:
- Console type validation:
self._console_type in ("spice", "spice+agent") - Connection target: SPICE TCP port
- Log messages reference SPICE instead of VNC
Controller Layer Proxy
File: gns3server/api/routes/controller/nodes.py
@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)
):
"""
SPICE WebSocket console.
Required privilege: Node.Console
"""
# 1. Authentication & Authorization
if current_user is None:
return
# 2. Build compute layer URL
compute = node.compute
spice_console_compute_url = (
f"{websocket.url.scheme}://{compute.host}:{compute.port}"
f"/v3/compute/projects/{node.project.id}/{node.node_type}/nodes/{node.id}/console/spice"
)
# 3. Forward to compute layer
async def spice_receive(spice_console_compute):
"""Client → Compute"""
while True:
data = await websocket.receive_bytes()
await spice_console_compute.send_bytes(data)
async with HTTPClient.get_client().ws_connect(
spice_console_compute_url,
auth=aiohttp.BasicAuth(user, password),
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) # Compute → Client
Authentication Flow
Compute Layer Authentication
File: gns3server/api/routes/compute/dependencies/authentication.py
async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSocket]:
server_settings = Config.instance().settings.Server
# Accept connection first
await websocket.accept()
# Skip auth if disabled
if not server_settings.enable_http_auth:
return websocket
# Extract Authorization header
authorization = websocket.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
# Validate Basic Auth
if scheme.lower() != "basic":
await websocket.close(code=1008)
return websocket
# Decode and verify credentials
data = base64.b64decode(param).decode("ascii")
username, separator, password = data.partition(":")
username_valid = secrets.compare_digest(username, server_settings.compute_username)
password_valid = secrets.compare_digest(password, server_settings.compute_password.get_secret_value())
if not (username_valid and password_valid):
await websocket.close(code=1008)
return websocket
return websocket
Controller Layer Authentication
# WebSocket dependency for privilege checking
async def has_privilege_on_websocket(privilege_id: str):
async def _has_privilege(websocket: WebSocket):
# 1. Extract and verify JWT token
token = websocket.query_params.get("ticket")
current_user = decode_jwt_token(token)
# 2. Check privilege
if not check_privilege(current_user, privilege_id):
await websocket.close(code=1008)
return None
return current_user
return _has_privilege
Usage Examples
JavaScript Client (noVNC)
// Connect to VNC console
const host = "gns3-server.example.com";
const port = 3080; // GNS3 controller port
const projectId = "uuid";
const nodeId = "uuid";
const token = "jwt_token"; // User authentication token
const vncUrl = `wss://${host}:${port}/v3/controller/projects/${projectId}/qemu/nodes/${nodeId}/console/vnc?ticket=${token}`;
const rfb = new RFB({
target: document.getElementById('vnc-canvas'),
url: vncUrl,
credentials: { password: '' } // VNC password (if configured)
});
rfb.addEventListener("connect", () => {
console.log("Connected to VNC console");
});
rfb.addEventListener("disconnect", () => {
console.log("Disconnected from VNC console");
});
JavaScript Client (spice-html5)
// Connect to SPICE console
const host = "gns3-server.example.com";
const port = 3080;
const projectId = "uuid";
const nodeId = "uuid";
const token = "jwt_token";
const spiceUrl = `wss://${host}:${port}/v3/controller/projects/${projectId}/qemu/nodes/${nodeId}/console/spice?ticket=${token}`;
const client = new SpiceHtml5.Client({
uri: spiceUrl,
screenId: 'spice-screen',
password: '', // SPICE password (if configured)
tls: true // Use TLS
});
client.addEventListener('connect', () => {
console.log("Connected to SPICE console");
});
client.connect();
Python Client (websockets)
import asyncio
import websockets
import base64
async def connect_vnc_console(host, port, project_id, node_id, username, password):
# Compute endpoint URL
url = f"ws://{host}:{port}/v3/compute/projects/{project_id}/qemu/nodes/{node_id}/console/vnc"
# HTTP Basic Auth
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
headers = {"Authorization": f"Basic {credentials}"}
async with websockets.connect(url, extra_headers=headers) as ws:
# Send/receive VNC binary data
while True:
data = await ws.recv()
# Process VNC protocol data...
asyncio.run(connect_vnc_console("localhost", 3080, "...", "...", "admin", "password"))
cURL (WebSocket Test)
# Test WebSocket connection (using websocat)
websocat \
-H "Authorization: Basic $(echo -n 'admin:password' | base64)" \
ws://localhost:3080/v3/compute/projects/{project_id}/qemu/nodes/{node_id}/console/vnc
Comparison: Old vs New Architecture
Old Architecture (websockify standalone)
Browser → websockify process 1 → VNC TCP (5900)
→ websockify process 2 → VNC TCP (5901)
→ websockify process N → VNC TCP (590N)
Issues:
- Each node requires separate websockify process
- Each node consumes additional WebSocket port
- No authentication on websockify port
- Complex process lifecycle management
- Resource intensive (many processes)
New Architecture (GNS3 API WebSocket)
Browser → GNS3 Controller WebSocket (single port)
→ GNS3 Compute WebSocket
→ Internal TCP bridge to VNC/SPICE
Advantages:
- Single WebSocket endpoint
- Integrated authentication
- No external processes
- Consistent with telnet console
- Lower resource footprint
Configuration
Server Settings
File: gns3server/config.py
Server:
# Enable/disable compute authentication
enable_http_auth: true
# Compute credentials (for controller → compute auth)
compute_username: "admin"
compute_password: "password"
# Console host configuration
console_host: "0.0.0.0" # or "::" for IPv6
QEMU VM Console Configuration
# Start QEMU VM with SPICE console
node = await qemu_manager.create_node(
project_id=project_id,
name="ubuntu-vm",
console_type="spice+agent", # SPICE with guest agent
console=5900, # SPICE port
# ... other QEMU parameters
)
Resulting QEMU Command:
qemu-system-x86_64 \
-spice addr=0.0.0.0,port=5900,disable-ticketing \
-vga qxl \
-device virtio-serial \
-chardev spicevmc,id=vdagent,debug=0,name=vdagent \
-device virtserialport,chardev=vdagent,name=com.redhat.spice.0 \
# ... other QEMU options
Docker Container Console Configuration
# Start Docker container with VNC console
node = await docker_manager.create_node(
project_id=project_id,
name="ubuntu-container",
image="ubuntu:latest",
console_type="vnc",
console=5900,
console_resolution="1280x720",
# ... other Docker parameters
)
Result:
- TigerVNC server starts in container
- VNC server listens on port 5900
- WebSocket endpoint available at
/console/vnc
Performance Considerations
Buffer Sizes
- VNC frames: Up to 65536 bytes per read (larger for graphics)
- SPICE data: Up to 65536 bytes per read
- WebSocket → TCP: Unbuffered (immediate write)
- TCP → WebSocket: Buffered reads for efficiency
Concurrent Connections
- Multiple clients: Supported (like telnet console)
- Broadcast behavior: Each WebSocket connection is independent
- Resource usage: Linear with number of concurrent connections
Network Bandwidth
| Console Type | Typical Bandwidth | Notes |
|---|---|---|
| Telnet | < 1 KB/s | Text only |
| VNC | 1-10 MB/s | Depends on screen changes |
| SPICE | 1-20 MB/s | Variable compression, can be higher with video |
Troubleshooting
Common Issues
1. Connection Refused
Symptom: WebSocket connection to 'ws://...' failed: Error in connection establishment
Possible Causes:
- Node not started
- Wrong console type configured
- Firewall blocking port
Solution:
# Check node status
curl -X GET http://localhost:3080/v3/controller/projects/{project_id}/qemu/nodes/{node_id}
# Verify console type
curl -X GET http://localhost:3080/v3/controller/projects/{project_id}/qemu/nodes/{node_id} | jq '.console_type'
2. Authentication Failed
Symptom: WebSocket closes with code 1008
Possible Causes:
- Invalid credentials
- Missing Authorization header
enable_http_authenabled but no credentials provided
Solution:
# Verify compute credentials
# In gns3_server configuration
Server.compute_username = "admin"
Server.compute_password = "password"
# Test with curl
curl -v \
-H "Authorization: Basic $(echo -n 'admin:password' | base64)" \
http://localhost:3080/v3/compute/projects/{project_id}/qemu/nodes/{node_id}
3. Console Type Mismatch
Symptom: Node console type is not vnc or Node console type is not SPICE
Possible Causes:
- Node configured with different console type
- Wrong WebSocket endpoint used
Solution:
# Check actual console type
curl http://localhost:3080/v3/controller/projects/{project_id}/qemu/nodes/{node_id} | jq '.console_type'
# Use correct endpoint
# - If console_type == "vnc": /console/vnc
# - If console_type == "spice" or "spice+agent": /console/spice
4. SPICE Not Working
Symptom: Cannot connect to SPICE console
Possible Causes:
- QEMU not built with SPICE support
- SPICE port already in use
- Node not started
Solution:
# Check QEMU SPICE support
qemu-system-x86_64 --help | grep spice
# Check if SPICE port is listening
netstat -tuln | grep 5900
# Check QEMU process
ps aux | grep qemu-system | grep spice
Testing
Unit Tests
Test VNC WebSocket forwarding:
async def test_vnc_websocket_console():
# Create QEMU node with VNC console
node = QemuVM(
name="test-vm",
console_type="vnc",
console=5900,
# ...
)
# Start node
await node.start()
# Create mock WebSocket
websocket = MockWebSocket()
# Connect
await node.start_vnc_websocket_console(websocket)
# Verify data forwarding
assert websocket.sent_data # VNC data received
Integration Tests
Test end-to-end VNC console:
# 1. Start GNS3 server
gns3server --local
# 2. Create project with QEMU VM
curl -X POST http://localhost:3080/v3/controller/projects \
-H "Content-Type: application/json" \
-d '{"name": "test-project"}'
# 3. Start QEMU VM with VNC
# (via API or GUI)
# 4. Connect WebSocket
websocat -H "Authorization: Basic ..." \
ws://localhost:3080/v3/compute/projects/{id}/qemu/nodes/{id}/console/vnc
# 5. Send VNC handshake
# (requires RFB protocol client)
Future Enhancements
Potential Improvements
-
WebSocket Compression
- Enable permessage-deflate compression
- Reduce bandwidth for text-heavy consoles
-
Session Recording
- Record VNC/SPICE sessions for playback
- Useful for training and debugging
-
Clipboard Integration
- SPICE vdagent already supports clipboard
- Could expose via WebSocket extensions
-
File Transfer
- SPICE supports folder sharing via webdav
- Could integrate with GNS3 file management
-
Audio Support
- SPICE supports audio streaming
- Could add WebSocket audio forwarding
Security Considerations
Authentication
- ✅ Controller Layer: JWT token + RBAC privilege check
- ✅ Compute Layer: HTTP Basic Auth
- ✅ No standalone websockify: Single authentication point
- ❌ Old websockify approach: No authentication on websockify port
Authorization
- Privilege Required:
Node.Console - Project Isolation: Users can only access consoles in projects they have access to
- Node Access Control: Checked via
dep_nodedependency
Transport Security
- WebSocket (ws://): Unencrypted, for development
- WebSocket Secure (wss://): Encrypted, recommended for production
- SSL/TLS: Configured via GNS3 server SSL settings
Console Protocol Security
- VNC: Uses RFB protocol, supports password authentication
- SPICE: Supports ticket-based authentication (
disable-ticketingin current implementation) - Recommendation: Enable VNC/SPICE passwords for production
References
Files Modified
| File | Description |
|---|---|
gns3server/compute/base_node.py |
Add start_vnc_websocket_console() and start_spice_websocket_console() methods |
gns3server/api/routes/compute/qemu_nodes.py |
Add /{node_id}/console/vnc and /{node_id}/console/spice endpoints |
gns3server/api/routes/compute/docker_nodes.py |
Add /{node_id}/console/vnc and /{node_id}/console/spice endpoints |
gns3server/api/routes/controller/nodes.py |
Add controller layer WebSocket proxy endpoints |
Commits
ba92b405- feat: add VNC WebSocket console support for Docker and QEMU nodes- Current implementation extends this with SPICE support
Related Documentation
External Libraries
- noVNC: HTML5 VNC Client (https://github.com/novnc/noVNC)
- spice-html5: HTML5 SPICE Client (https://github.com/spice-html5/spice-html5)
Migration Guide
From Standalone websockify
Old Approach (not recommended):
# Start websockify for each node
websockify 10001 localhost:5900 # Process 1
websockify 10002 localhost:5901 # Process 2
websockify 10003 localhost:5902 # Process 3
New Approach (recommended):
# No websockify processes needed
# Just use GNS3 WebSocket API
WS /v3/controller/projects/{id}/qemu/nodes/{id}/console/vnc
WS /v3/controller/projects/{id}/qemu/nodes/{id}/console/spice
Client Migration:
// Old: Connect directly to websockify
const url = `ws://server:${websockify_port}/`;
// New: Connect through GNS3 API
const url = `wss://server:3080/v3/controller/projects/${projectId}/qemu/nodes/${nodeId}/console/vnc?ticket=${token}`;
Changelog
Version 1.0 (2026-03-16)
Added:
- VNC WebSocket console support for QEMU and Docker nodes
- SPICE WebSocket console support for QEMU nodes
- Controller layer WebSocket proxy endpoints
- Compute layer WebSocket endpoints with authentication
- Bidirectional binary data forwarding in BaseNode
Changed:
- N/A (new feature)
Deprecated:
- Standalone websockify approach (still functional but not recommended)
Fixed:
- N/A (new feature)