mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
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:
parent
ba92b405da
commit
c98f802362
871
docs/features/vnc-spice-websocket-console.md
Normal file
871
docs/features/vnc-spice-websocket-console.md
Normal file
@ -0,0 +1,871 @@
|
||||
# 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
|
||||
|
||||
1. **Unified WebSocket Architecture**: All console types (Telnet, VNC, SPICE) use consistent WebSocket endpoints
|
||||
2. **Authentication Integration**: Leverage existing GNS3 authentication mechanisms
|
||||
3. **No External Dependencies**: Eliminate standalone websockify processes and additional port allocations
|
||||
4. **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**:
|
||||
```http
|
||||
Upgrade: websocket
|
||||
Connection: Upgrade
|
||||
Authorization: Basic <base64(username:password)>
|
||||
```
|
||||
|
||||
**Response**:
|
||||
- Binary WebSocket connection for VNC protocol (RFB)
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
# 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**:
|
||||
```http
|
||||
Upgrade: websocket
|
||||
Connection: Upgrade
|
||||
Authorization: Basic <base64(username:password)>
|
||||
```
|
||||
|
||||
**Response**:
|
||||
- Binary WebSocket connection for SPICE protocol
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
# 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**:
|
||||
```json
|
||||
{
|
||||
"console_type": "spice+agent",
|
||||
"console": 5900
|
||||
}
|
||||
```
|
||||
|
||||
**Docker Container**:
|
||||
```json
|
||||
{
|
||||
"console_type": "vnc",
|
||||
"console": 5900
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### BaseNode WebSocket Forwarding
|
||||
|
||||
**File**: `gns3server/compute/base_node.py`
|
||||
|
||||
#### VNC Console Implementation
|
||||
|
||||
```python
|
||||
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`
|
||||
|
||||
```python
|
||||
@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`
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
# 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)
|
||||
|
||||
```javascript
|
||||
// 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)
|
||||
|
||||
```javascript
|
||||
// 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)
|
||||
|
||||
```python
|
||||
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)
|
||||
|
||||
```bash
|
||||
# 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`
|
||||
|
||||
```yaml
|
||||
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
|
||||
|
||||
```python
|
||||
# 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**:
|
||||
```bash
|
||||
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
|
||||
|
||||
```python
|
||||
# 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**:
|
||||
```bash
|
||||
# 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_auth` enabled but no credentials provided
|
||||
|
||||
**Solution**:
|
||||
```python
|
||||
# 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**:
|
||||
```bash
|
||||
# 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**:
|
||||
```bash
|
||||
# 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**:
|
||||
```python
|
||||
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**:
|
||||
```bash
|
||||
# 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
|
||||
|
||||
1. **WebSocket Compression**
|
||||
- Enable permessage-deflate compression
|
||||
- Reduce bandwidth for text-heavy consoles
|
||||
|
||||
2. **Session Recording**
|
||||
- Record VNC/SPICE sessions for playback
|
||||
- Useful for training and debugging
|
||||
|
||||
3. **Clipboard Integration**
|
||||
- SPICE vdagent already supports clipboard
|
||||
- Could expose via WebSocket extensions
|
||||
|
||||
4. **File Transfer**
|
||||
- SPICE supports folder sharing via webdav
|
||||
- Could integrate with GNS3 file management
|
||||
|
||||
5. **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_node` dependency
|
||||
|
||||
### 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-ticketing` in 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
|
||||
|
||||
- [Telnet Console Documentation](../bugs/telnet-server-connection-race-condition.md)
|
||||
- [GNS3 API Documentation](./openapi.json)
|
||||
- [noVNC Documentation](https://github.com/novnc/noVNC)
|
||||
- [SPICE Protocol](https://www.spice-space.org/documentation.html)
|
||||
|
||||
### 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):
|
||||
```python
|
||||
# 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):
|
||||
```python
|
||||
# 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**:
|
||||
```javascript
|
||||
// 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)
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user