Skip to content

Commit 1704fff

Browse files
committed
Add HTTPS console streaming and tokens
1 parent ae79cb5 commit 1704fff

9 files changed

Lines changed: 195 additions & 24 deletions

File tree

firecracker.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,13 @@ def op_console(ctx):
367367
"""POST /v1/vms/{name}/console — obtain VNC bridge connection info."""
368368
url = f"{ctx.agent.base_url}/vms/{ctx.vm_name}/console"
369369
data = _json_or_fail(_req("POST", url, ctx.agent))
370+
console_obj = data.get("console")
371+
if isinstance(console_obj, dict):
372+
path = console_obj.get("path")
373+
if path and not console_obj.get("url"):
374+
base = ctx.agent.base_url.rsplit("/v1", 1)[0]
375+
console_obj["url"] = f"{base}{path}"
376+
_ok(data)
370377
console_host = getattr(ctx.agent, "console_host", "") or ""
371378
resp_host = data.get("host")
372379
if console_host and (not resp_host or resp_host in {"0.0.0.0", "127.0.0.1", "::"}):

host-agent/api/handlers.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,30 @@
2525
from utils.validation import validate_name
2626
from utils.vnc_console import VNCConsoleManager
2727

28+
try:
29+
from utils.console_tokens import ConsoleTokenManager
30+
except ImportError: # pragma: no cover
31+
ConsoleTokenManager = None # type: ignore
32+
2833
logger = logging.getLogger("fc-agent")
2934

3035

3136
class APIHandlers:
3237

33-
def __init__(self, agent_defaults: Dict[str, Any], ui_config: Optional[Dict[str, Any]] = None):
38+
def __init__(
39+
self,
40+
agent_defaults: Dict[str, Any],
41+
ui_config: Optional[Dict[str, Any]] = None,
42+
console_token_manager: Optional["ConsoleTokenManager"] = None,
43+
):
3444
self.agent_defaults = agent_defaults
3545
self.ui_config = ui_config or {"enabled": True, "session_timeout_seconds": 1800}
3646
self.vm_manager = VMManager()
3747
self.vm_lifecycle = VMLifecycle(agent_defaults)
3848
self.config_manager = ConfigManager(agent_defaults)
3949
self.state_manager = StateManager(agent_defaults)
4050
self.vnc_console = VNCConsoleManager(agent_defaults)
51+
self.console_tokens = console_token_manager
4152

4253
def v1_ui_config(self) -> Dict[str, Any]:
4354
cfg = self.ui_config or {}
@@ -477,14 +488,33 @@ def v1_vm_console_start(self, vm_name: str) -> Dict[str, Any]:
477488
"""Start (or reuse) the VNC bridge for a VM console."""
478489
try:
479490
info = self.vnc_console.ensure_console(vm_name)
491+
console_obj: Dict[str, Any]
492+
if info.get("direct_proxy") and self.console_tokens:
493+
token = self.console_tokens.issue(
494+
vm_name,
495+
info.get("port"),
496+
info.get("password"),
497+
info.get("session_name", ""),
498+
)
499+
console_obj = {
500+
"path": f"/console/ws/{token}",
501+
"protocol": "https",
502+
"passwordonetimeuseonly": True,
503+
}
504+
else:
505+
console_obj = {
506+
"host": info.get("bind_host") or self.vnc_console.bind_host,
507+
"port": info.get("port"),
508+
"password": info.get("password"),
509+
"protocol": "vnc",
510+
"passwordonetimeuseonly": False,
511+
}
480512
return {
481513
"status": "success",
482514
"message": f"VNC console ready for VM {vm_name}",
483515
"vm_name": vm_name,
484-
"host": info.get("host"),
485-
"port": info.get("port"),
486-
"password": info.get("password"),
487516
"created_at": info.get("created_at"),
517+
"console": console_obj,
488518
}
489519
except RuntimeError as exc:
490520
raise HTTPException(status_code=400, detail=str(exc)) from exc

host-agent/api/routes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ def register_routes(
1414
agent_defaults: Dict[str, Any],
1515
auth_dependency: Optional[Any] = None,
1616
ui_config: Optional[Dict[str, Any]] = None,
17+
console_token_manager: Optional[Any] = None,
1718
) -> None:
1819
"""Register all API routes with the FastAPI application."""
19-
handlers = APIHandlers(agent_defaults, ui_config=ui_config)
20+
handlers = APIHandlers(agent_defaults, ui_config=ui_config, console_token_manager=console_token_manager)
2021
deps = [Depends(auth_dependency)] if auth_dependency else []
2122
protected = {"dependencies": deps} if deps else {}
2223

host-agent/debian/firecracker-agent.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
"geometry": "1024x768x24",
2727
"xterm_geometry": "127x45",
2828
"font_family": "Monospace",
29-
"font_size": 10
29+
"font_size": 10,
30+
"direct_proxy_enabled": false,
31+
"direct_token_ttl": 300
3032
}
3133
},
3234
"security": {

host-agent/debian/install

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ host-agent/utils/validation.py usr/lib/firecracker-cloudsta
2525
host-agent/utils/filesystem.py usr/lib/firecracker-cloudstack-agent/utils/
2626
host-agent/utils/tmux.py usr/lib/firecracker-cloudstack-agent/utils/
2727
host-agent/utils/vnc_console.py usr/lib/firecracker-cloudstack-agent/utils/
28+
host-agent/utils/console_tokens.py usr/lib/firecracker-cloudstack-agent/utils/
2829
host-agent/tools/fc_shutdown_service.py usr/lib/firecracker-cloudstack-agent/tools/
2930
host-agent/api/__init__.py usr/lib/firecracker-cloudstack-agent/api/
3031
host-agent/api/handlers.py usr/lib/firecracker-cloudstack-agent/api/

host-agent/firecracker-agent.json-file-example

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@
1818
"net": {
1919
"driver": "linux-bridge-vlan",
2020
"host_bridge": "cloudbr1"
21+
},
22+
"console": {
23+
"bind_host": "0.0.0.0",
24+
"port_min": 5900,
25+
"port_max": 5999,
26+
"geometry": "1024x768x24",
27+
"xterm_geometry": "127x45",
28+
"font_family": "Monospace",
29+
"font_size": 10,
30+
"read_only": false,
31+
"direct_proxy_enabled": true,
32+
"direct_token_ttl": 300
2133
}
2234
},
2335
"security": {
@@ -34,4 +46,3 @@
3446
"service": "firecracker-agent"
3547
}
3648
}
37-

host-agent/firecracker-agent.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
# under the License.
1919
from __future__ import annotations
2020

21+
import asyncio
22+
import contextlib
2123
import logging
2224
import os
2325
import ssl
@@ -26,7 +28,7 @@
2628

2729
import typer
2830
import uvicorn
29-
from fastapi import FastAPI, HTTPException, Request
31+
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
3032
from fastapi.staticfiles import StaticFiles
3133
from fastapi.exceptions import RequestValidationError
3234
from starlette.responses import FileResponse, JSONResponse, RedirectResponse
@@ -39,6 +41,7 @@
3941
from orchestration import VMLifecycle
4042
from utils.auth import PamError, build_auth_dependency
4143
from utils.filesystem import set_agent_defaults
44+
from utils.console_tokens import ConsoleTokenManager
4245

4346
# Global variables
4447
logger = logging.getLogger("fc-agent")
@@ -58,6 +61,7 @@
5861
IS_API_MODE = True
5962
UI_STATIC_MOUNTED = False
6063
UI_CONFIG: Dict[str, Any] = {"enabled": True, "session_timeout_seconds": 1800}
64+
CONSOLE_TOKEN_MANAGER: Optional[ConsoleTokenManager] = None
6165
# Initialize FastAPI app
6266
app = FastAPI(title="Firecracker Agent", version="1.0.0")
6367

@@ -198,6 +202,67 @@ def v1_config_effective() -> Dict[str, Any]:
198202
return {"status": "success", "config": AGENT_DEFAULTS}
199203

200204

205+
@app.websocket("/console/ws/{token}")
206+
async def console_websocket(token: str, websocket: WebSocket):
207+
if not CONSOLE_TOKEN_MANAGER:
208+
await websocket.close(code=1008)
209+
return
210+
info = CONSOLE_TOKEN_MANAGER.consume(token)
211+
if not info:
212+
await websocket.close(code=1008)
213+
return
214+
port = int(info.get("port", 0) or 0)
215+
if port <= 0:
216+
await websocket.close(code=1008)
217+
return
218+
try:
219+
reader, writer = await asyncio.open_connection("127.0.0.1", port)
220+
except Exception:
221+
await websocket.close(code=1011)
222+
return
223+
224+
await websocket.accept()
225+
close_event = asyncio.Event()
226+
227+
async def ws_to_tcp():
228+
try:
229+
while True:
230+
msg = await websocket.receive()
231+
if msg["type"] == "websocket.disconnect":
232+
break
233+
data = msg.get("bytes")
234+
if data is None:
235+
text = msg.get("text")
236+
if text is None:
237+
continue
238+
data = text.encode("utf-8")
239+
writer.write(data)
240+
await writer.drain()
241+
except WebSocketDisconnect:
242+
pass
243+
finally:
244+
close_event.set()
245+
writer.close()
246+
with contextlib.suppress(Exception):
247+
await writer.wait_closed()
248+
249+
async def tcp_to_ws():
250+
try:
251+
while not close_event.is_set():
252+
data = await reader.read(4096)
253+
if not data:
254+
break
255+
await websocket.send_bytes(data)
256+
except Exception:
257+
pass
258+
finally:
259+
close_event.set()
260+
with contextlib.suppress(Exception):
261+
await websocket.close()
262+
263+
await asyncio.gather(ws_to_tcp(), tcp_to_ws())
264+
265+
201266
# FastAPI event handlers
202267
@app.on_event("startup")
203268
async def startup_event():
@@ -210,6 +275,11 @@ async def startup_event():
210275
AGENT_DEFAULTS = AGENT_CFG.get("defaults", {})
211276
config_manager.agent_defaults = AGENT_DEFAULTS
212277
set_agent_defaults(AGENT_DEFAULTS)
278+
console_cfg = AGENT_DEFAULTS.get("console", {}) if isinstance(AGENT_DEFAULTS, dict) else {}
279+
token_ttl = int(console_cfg.get("direct_token_ttl", 300))
280+
direct_enabled = bool(console_cfg.get("direct_proxy_enabled", False))
281+
global CONSOLE_TOKEN_MANAGER
282+
CONSOLE_TOKEN_MANAGER = ConsoleTokenManager(ttl_seconds=token_ttl) if direct_enabled else None
213283

214284
logger.info("Configuration loaded successfully")
215285
logger.info("AGENT_CFG keys: %s", list(AGENT_CFG.keys()))
@@ -224,7 +294,7 @@ async def startup_event():
224294
# Register API routes with loaded configuration
225295
if AUTH_DEPENDENCY is None:
226296
AUTH_DEPENDENCY = _configure_auth_dependency(AGENT_CFG.get("auth", {}))
227-
register_routes(app, AGENT_DEFAULTS, AUTH_DEPENDENCY, UI_CONFIG)
297+
register_routes(app, AGENT_DEFAULTS, AUTH_DEPENDENCY, UI_CONFIG, console_token_manager=CONSOLE_TOKEN_MANAGER)
228298
if UI_CONFIG.get("enabled"):
229299
_mount_ui_static_if_available()
230300
else:

host-agent/utils/console_tokens.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""Simple in-memory console token manager."""
4+
import secrets
5+
import threading
6+
import time
7+
from typing import Dict, Optional
8+
9+
10+
class ConsoleTokenManager:
11+
def __init__(self, ttl_seconds: int = 300):
12+
self.ttl = int(ttl_seconds or 300)
13+
self._tokens: Dict[str, Dict[str, object]] = {}
14+
self._lock = threading.Lock()
15+
16+
def issue(self, vm_name: str, port: int, password: Optional[str], session_name: str) -> str:
17+
token = secrets.token_urlsafe(16)
18+
entry = {
19+
"vm_name": vm_name,
20+
"port": int(port),
21+
"password": password,
22+
"session": session_name,
23+
"expires_at": time.time() + self.ttl,
24+
}
25+
with self._lock:
26+
self._tokens[token] = entry
27+
return token
28+
29+
def consume(self, token: str) -> Optional[Dict[str, object]]:
30+
if not token:
31+
return None
32+
with self._lock:
33+
entry = self._tokens.pop(token, None)
34+
if not entry:
35+
return None
36+
if entry["expires_at"] < time.time():
37+
return None
38+
return entry
39+
40+
def purge_expired(self) -> None:
41+
cutoff = time.time()
42+
with self._lock:
43+
expired = [tok for tok, info in self._tokens.items() if info["expires_at"] < cutoff]
44+
for tok in expired:
45+
self._tokens.pop(tok, None)

host-agent/utils/vnc_console.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ def __init__(self, agent_defaults: Dict[str, Any]):
4848
self.font_family = console_defaults.get("font_family") or "Monospace"
4949
self.font_size = int(console_defaults.get("font_size", 10))
5050
self.read_only = bool(console_defaults.get("read_only", False))
51+
self.direct_proxy = bool(console_defaults.get("direct_proxy_enabled", False))
52+
if self.direct_proxy:
53+
self.bind_host = "127.0.0.1"
54+
self.require_password = not self.direct_proxy
5155

5256
self.tmux = TmuxManager()
5357

@@ -58,7 +62,7 @@ def ensure_console(self, vm_name: str) -> Dict[str, Any]:
5862
current_state = self._load_state(vm_state_path)
5963
if current_state and self._state_active(current_state):
6064
logger.debug("Reusing existing VNC console for %s (state=%s)", vm_name, current_state)
61-
return self._response_payload(current_state)
65+
return current_state
6266

6367
if current_state:
6468
logger.debug("Cleaning up stale VNC console for %s", vm_name)
@@ -70,8 +74,11 @@ def ensure_console(self, vm_name: str) -> Dict[str, Any]:
7074
raise RuntimeError(f"tmux session {session_name} not found; VM console is not available")
7175

7276
port = self._allocate_port()
73-
password = self._generate_password()
74-
password_file = self._write_password_file(vm_name, password)
77+
password = None
78+
password_file = None
79+
if self.require_password:
80+
password = self._generate_password()
81+
password_file = self._write_password_file(vm_name, password)
7582
display, xvfb_proc = self._start_xvfb(vm_name)
7683
xterm_proc = self._start_xterm(display, vm_name, session_name)
7784
x11vnc_proc = self._start_x11vnc(vm_name, display, port, password_file)
@@ -85,14 +92,15 @@ def ensure_console(self, vm_name: str) -> Dict[str, Any]:
8592
"x11vnc_pid": x11vnc_proc.pid,
8693
"port": port,
8794
"password": password,
88-
"password_file": str(password_file),
95+
"password_file": str(password_file) if password_file else None,
8996
"bind_host": self.bind_host,
9097
"session_name": session_name,
98+
"direct_proxy": self.direct_proxy,
9199
}
92100
self._write_state(vm_state_path, state)
93101
logger.debug("VNC console state stored for %s: %s", vm_name, state)
94102
self._monitor_console(vm_name, state)
95-
return self._response_payload(state)
103+
return state
96104

97105
def stop_console(self, vm_name: str) -> Dict[str, Any]:
98106
"""Terminate VNC bridge for the VM."""
@@ -150,14 +158,7 @@ def _state_active(self, state: Dict[str, Any]) -> bool:
150158
return True
151159

152160
def _response_payload(self, state: Dict[str, Any]) -> Dict[str, Any]:
153-
return {
154-
"status": "success",
155-
"vm_name": state.get("vm_name"),
156-
"host": state.get("bind_host", self.bind_host),
157-
"port": int(state.get("port")),
158-
"password": state.get("password"),
159-
"created_at": state.get("created_at"),
160-
}
161+
return state
161162

162163
def _cleanup_state(self, state: Dict[str, Any]) -> None:
163164
for key in ("x11vnc_pid", "xterm_pid", "xvfb_pid"):
@@ -339,15 +340,18 @@ def _start_x11vnc(self, vm_name: str, display: str, port: int, password_file: Pa
339340
display,
340341
"-rfbport",
341342
str(port),
342-
"-rfbauth",
343-
str(password_file),
344343
"-once",
345344
"-shared",
346345
"-noxdamage",
347346
"-nolookup",
348347
"-o",
349348
str(log_file),
350349
]
350+
if self.direct_proxy:
351+
cmd.append("-localhost")
352+
cmd.append("-nopw")
353+
elif password_file:
354+
cmd.extend(["-rfbauth", str(password_file)])
351355
if self.bind_host not in ("127.0.0.1", "::1"):
352356
cmd.extend(["-listen", self.bind_host])
353357
else:

0 commit comments

Comments
 (0)