Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4d44291
add direct vnc access to vm
enzok Jun 4, 2026
e9f4a17
web: Hide redundant jQuery UI titlebar in Guacamole console
enzok Jun 9, 2026
60e27be
web: Add VNC Console dropdown option in header navigation bar
enzok Jun 9, 2026
7a202ce
web: Support VNC Console VM start, shutdown options and database task…
enzok Jun 9, 2026
f3014a2
web: Add warning when connecting to an already active Direct VNC cons…
enzok Jun 9, 2026
e2525c0
web: Allow user to select snapshot when starting VM from VNC console
enzok Jun 9, 2026
ff800d9
Implement wait screen with cancel button during VM startup and add CS…
enzok Jun 9, 2026
d1f7878
Add dynamic routing (Tor/VPN/Dirty line) selector to VNC host console
enzok Jun 9, 2026
46665e4
Fix unclosed started_by_console template tag in index.html
enzok Jun 9, 2026
72f5d49
Add detailed logging to direct_vnc_vm_route view for debugging
enzok Jun 9, 2026
2c38e44
Fix active session block edge case by checking VM state first and cle…
enzok Jun 9, 2026
90d2249
Default VNC VM routing session to none (no internet) on startup
enzok Jun 9, 2026
5a98095
Add guest clock sync to EST after VM boot in Direct VNC Console
enzok Jun 9, 2026
8e9d46d
Add snapshot create and delete capabilities to Direct VNC Console
enzok Jun 10, 2026
85ab109
Display active snapshot on VNC window and add dynamic list of snapsho…
enzok Jun 10, 2026
1b733fc
Safeguard task-based running VMs from stale lock automatic cleanup
enzok Jun 10, 2026
359e596
Switch to database-driven startup indicator to support multi-process …
enzok Jun 10, 2026
f3d897a
Implement robust fallback for active snapshot name using session and …
enzok Jun 10, 2026
c6fe8f9
Move Submit
enzok Jun 10, 2026
12a9d56
Fix VNC Console header item visibility on configuration disable
enzok Jun 10, 2026
c127327
allauth: read OIDC claims from nested userinfo in extra_data
node5-sublime Jun 11, 2026
ae1f3b7
allauth: make _claims idempotent + simplify email lookup (review feed…
node5-sublime Jun 11, 2026
9daa007
Merge branch 'master' into feat-vncclient
enzok Jun 15, 2026
3fd3593
Fixes
enzok Jun 15, 2026
2519b2c
Ruff updates
enzok Jun 15, 2026
9450fdc
Fix typo in setting name
enzok Jun 15, 2026
0798d05
guac: Secure VNC snapshots, prevent event-loop blocking, and clean up…
enzok Jun 15, 2026
d8d35bc
Merge pull request #3076 from enzok/feat-vncclient
kevoreilly Jun 24, 2026
fa28299
Merge pull request #3070 from wmetcalf/fix/oidc-claims-nested-extra-data
kevoreilly Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions conf/default/web.conf.default
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ vnc_color_depth = 16
vnc_cursor = local
# Audio (enable only if needed, consumes bandwidth)
enable_audio = no
# Show a VNC Console dropdown of VM guests in the navigation bar
vnc_console_enabled = no
# Open VNC console sessions in a new tab
vnc_console_new_tab = no

[packages]
# VM tags may be used to specify on which guest machines a sample should be run
Expand Down
196 changes: 151 additions & 45 deletions web/guac/consumers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def _get_vnc_port(vm_label):
"""Look up VNC port for a VM from libvirt. Must be called from sync context."""
if not LIBVIRT_AVAILABLE:
return None

conn = None
try:
conn = libvirt.open(machinery_dsn)
Expand Down Expand Up @@ -63,6 +64,30 @@ def _get_vnc_port(vm_label):
pass


def _check_vm_running(vm_label):
"""Check if the VM is running in libvirt. Must be called from sync context."""
if not LIBVIRT_AVAILABLE:
return False

conn = None
try:
conn = libvirt.open(machinery_dsn)
if conn:
dom = conn.lookupByName(vm_label)
if dom:
state = dom.state(flags=0)
return state and state[0] == 1
except Exception as e:
logger.error("Error checking VM status for %s: %s", vm_label, e)
finally:
if conn:
try:
conn.close()
except Exception:
pass
return False


class GuacamoleWebSocketConsumer(AsyncWebsocketConsumer):
subprotocols = ["guacamole"]

Expand All @@ -73,6 +98,7 @@ def __init__(self, *args, **kwargs):
self.monitor_task = None
self.guac_token = None
self.guac_task_id = None
self.vm_label = None
self.is_closing = False
self.timeout_manager = None
self.timeout_task = None
Expand Down Expand Up @@ -137,26 +163,50 @@ async def connect(self):

self.guac_token = str(token)
self.guac_task_id = session_data["task_id"]
vm_label = session_data["vm_label"]
self.vm_label = session_data["vm_label"]
vm_label = self.vm_label

# 3. Verify task can still host an interactive session
task = await sync_to_async(db.view_task)(self.guac_task_id)
if not task or task.status not in ACTIVE_GUAC_TASK_STATUSES:
logger.warning(
"WebSocket rejected: task %s is not active for guac", self.guac_task_id
)
await self._delete_guac_session()
await self.close()
return

# 4. Look up VNC port server-side from libvirt
vnc_port = await sync_to_async(_get_vnc_port)(vm_label)
if not vnc_port:
logger.warning(
"WebSocket rejected: no VNC port for VM %s", vm_label
)
await self.close()
return
vnc_port = None
if self.guac_task_id > 0:
# 3. Verify task can still host an interactive session
task = await sync_to_async(db.view_task)(self.guac_task_id)
if not task or task.status not in ACTIVE_GUAC_TASK_STATUSES:
logger.warning(
"WebSocket rejected: task %s is not active for guac", self.guac_task_id
)
await self._delete_guac_session()
await self.close()
return

# 4. Look up VNC port server-side from libvirt
vnc_port = await sync_to_async(_get_vnc_port)(vm_label)
if not vnc_port:
logger.warning(
"WebSocket rejected: no VNC port for VM %s", vm_label
)
await self.close()
return
else:
# Direct VNC connection
guest_ip = session_data.get("guest_ip")
if not guest_ip:
# Autodiscover port given just the VM name
vnc_port = await sync_to_async(_get_vnc_port)(vm_label)
if not vnc_port:
logger.warning(
"WebSocket rejected: could not autodiscover VNC port for VM %s", vm_label
)
await self.close()
return
else:
try:
vnc_port = int(vm_label)
except ValueError:
logger.warning(
"WebSocket rejected: invalid direct VNC port %s", vm_label
)
await self.close()
return

# 5. Parse config
guacd_hostname = web_cfg.guacamole.guacd_host or "localhost"
Expand All @@ -174,22 +224,38 @@ async def connect(self):
raw_recording = params.get("recording_name", ["task-recording"])[0]
guacd_recording_name = re.sub(r"[^a-zA-Z0-9_-]", "", raw_recording)

if "rdp" in guest_protocol:
guest_host = session_data.get("guest_ip", vm_label)
if not guest_host:
guest_host = vm_label
guest_port = int(web_cfg.guacamole.guest_rdp_port) or 3389
ignore_cert = (
"true"
if web_cfg.guacamole.ignore_rdp_cert is True
else "false"
)
extra_args = {
"disable-wallpaper": "true",
"disable-theming": "true",
}
if self.guac_task_id > 0:
if "rdp" in guest_protocol:
guest_host = session_data.get("guest_ip", vm_label)
if not guest_host:
guest_host = vm_label
guest_port = int(web_cfg.guacamole.guest_rdp_port) or 3389
ignore_cert = (
"true"
if web_cfg.guacamole.ignore_rdp_cert is True
else "false"
)
extra_args = {
"disable-wallpaper": "true",
"disable-theming": "true",
}
else:
guest_host = web_cfg.guacamole.vnc_host or "localhost"
guest_port = vnc_port
ignore_cert = "false"
vnc_color_depth = str(
getattr(web_cfg.guacamole, "vnc_color_depth", 16)
)
vnc_cursor = getattr(web_cfg.guacamole, "vnc_cursor", "local")
extra_args = {
"color-depth": vnc_color_depth,
"cursor": vnc_cursor,
}
else:
guest_host = web_cfg.guacamole.vnc_host or "localhost"
# Direct VNC connection
guest_protocol = "vnc"
guest_ip = session_data.get("guest_ip")
guest_host = guest_ip or web_cfg.guacamole.vnc_host or "localhost"
guest_port = vnc_port
ignore_cert = "false"
vnc_color_depth = str(
Expand All @@ -204,6 +270,16 @@ async def connect(self):
# 6. Connect to guacd
self.client = GuacamoleClient(guacd_hostname, guacd_port)

logger.info(
"Guacamole connecting to guacd at %s:%s. Handshake: protocol=%s, host=%s, port=%s, recording_name=%s",
guacd_hostname,
guacd_port,
guest_protocol,
guest_host,
guest_port,
guacd_recording_name,
)

await sync_to_async(self.client.handshake)(
protocol=guest_protocol,
width=guest_width,
Expand All @@ -227,21 +303,27 @@ async def connect(self):
)

# 7. Initialize timeout handling
try:
vm_ip = session_data.get("guest_ip") or guest_host
self.timeout_manager = SessionTimeoutManager(
vm_ip=vm_ip,
user="unknown_user",
session_id=self.guac_token,
task_id=str(self.guac_task_id),
)
except Exception as e:
logger.error("Failed to initialize timeout manager: %s", e)
if self.guac_task_id > 0:
try:
vm_ip = session_data.get("guest_ip") or guest_host
self.timeout_manager = SessionTimeoutManager(
vm_ip=vm_ip,
user="unknown_user",
session_id=self.guac_token,
task_id=str(self.guac_task_id),
)
except Exception as e:
logger.error("Failed to initialize timeout manager: %s", e)
self.timeout_manager = None
else:
self.timeout_manager = None

# 8. Start background tasks
self.task = asyncio.create_task(self.read_guacd())
self.monitor_task = asyncio.create_task(self.monitor_task_status())
if self.guac_task_id > 0:
self.monitor_task = asyncio.create_task(self.monitor_task_status())
else:
self.monitor_task = asyncio.create_task(self.monitor_vm_status())
if self.timeout_manager and self.timeout_manager.idle_timeout_seconds > 0:
self.timeout_task = asyncio.create_task(self.monitor_timeout())
else:
Expand Down Expand Up @@ -276,6 +358,30 @@ async def monitor_task_status(self):
except Exception as e:
logger.error("Error in task monitor: %s", e)

async def monitor_vm_status(self):
"""Periodically check if the VM is still running. If not, release the lock and close."""
try:
while True:
await asyncio.sleep(TASK_POLL_INTERVAL)
if self.guac_task_id > 0:
break

is_running = await sync_to_async(_check_vm_running)(self.vm_label)

if not is_running:
logger.info("VM %s is no longer running, unlocking and disconnecting", self.vm_label)
db = Database()
machine = await sync_to_async(db.view_machine_by_label)(self.vm_label)
if machine and machine.locked:
await sync_to_async(db.unlock_machine)(machine)
await sync_to_async(db.session.commit)()
await self._close_websocket()
break
except asyncio.CancelledError:
pass
except Exception as e:
logger.error("Error in VM monitor: %s", e)

async def disconnect(self, code):
"""Clean up on WebSocket disconnect."""
self.is_closing = True
Expand Down
25 changes: 25 additions & 0 deletions web/guac/context_processors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from lib.cuckoo.common.config import Config
from lib.cuckoo.core.database import Database

web_cfg = Config("web")


def guac_vnc_console(request):
"""Context processor that exposes VNC Console settings and guests to templates."""
enabled = web_cfg.guacamole.get("vnc_console_enabled", False)
if isinstance(enabled, str):
enabled = enabled.lower() in ("yes", "true", "on", "1")
if not enabled:
return {"vnc_console_enabled": False}

db = Database()
machines = [machine.label for machine in db.list_machines(include_reserved=True)]
new_tab = web_cfg.guacamole.get("vnc_console_new_tab", True)
if isinstance(new_tab, str):
new_tab = new_tab.lower() in ("yes", "true", "on", "1")

return {
"vnc_console_enabled": True,
"vnc_console_machines": machines,
"vnc_console_new_tab": new_tab,
}
2 changes: 1 addition & 1 deletion web/guac/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

websocket_urlpatterns = [
re_path(
r"^guac/websocket-tunnel/(?P<session_id>\w+)/?$",
r"^guac/websocket-tunnel/(?P<session_id>[\w-]+)/?$",
GuacamoleWebSocketConsumer.as_asgi(),
),
]
Loading
Loading