From 35f26ce4ee5ac8475bf525779a89261170076599 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 15 Jan 2026 23:51:17 -0700 Subject: [PATCH] Initial RDP Implementation --- Data/Agent/Roles/role_RDP.py | 77 +++ Data/Engine/config.py | 45 ++ Data/Engine/server.py | 12 + Data/Engine/services/API/__init__.py | 2 + Data/Engine/services/API/devices/rdp.py | 195 +++++++ .../Engine/services/RemoteDesktop/__init__.py | 9 + .../services/RemoteDesktop/guacamole_proxy.py | 369 ++++++++++++ Data/Engine/web-interface/package.json | 1 + .../src/Devices/Device_Details.jsx | 17 + .../src/Devices/ReverseTunnel/RDP.jsx | 552 ++++++++++++++++++ 10 files changed, 1279 insertions(+) create mode 100644 Data/Agent/Roles/role_RDP.py create mode 100644 Data/Engine/services/API/devices/rdp.py create mode 100644 Data/Engine/services/RemoteDesktop/__init__.py create mode 100644 Data/Engine/services/RemoteDesktop/guacamole_proxy.py create mode 100644 Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx diff --git a/Data/Agent/Roles/role_RDP.py b/Data/Agent/Roles/role_RDP.py new file mode 100644 index 00000000..60ccf3ac --- /dev/null +++ b/Data/Agent/Roles/role_RDP.py @@ -0,0 +1,77 @@ +# ====================================================== +# Data\Agent\Roles\role_RDP.py +# Description: Optional RDP readiness helper for Borealis (Windows-only). +# +# API Endpoints (if applicable): None +# ====================================================== + +"""RDP readiness helper role (no-op unless enabled via environment flags).""" + +from __future__ import annotations + +import os +import subprocess +import time +from pathlib import Path + +ROLE_NAME = "RDP" +ROLE_CONTEXTS = ["system"] + + +def _log_path() -> Path: + root = Path(__file__).resolve().parents[2] / "Logs" + root.mkdir(parents=True, exist_ok=True) + return root / "rdp.log" + + +def _write_log(message: str) -> None: + ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()) + try: + _log_path().open("a", encoding="utf-8").write(f"[{ts}] [rdp-role] {message}\n") + except Exception: + pass + + +def _bool_env(name: str) -> bool: + value = os.environ.get(name) + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _enable_rdp_windows() -> None: + command = ( + "Set-ItemProperty -Path 'HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server' " + "-Name fDenyTSConnections -Value 0; " + "Set-Service -Name TermService -StartupType Automatic; " + "Start-Service -Name TermService; " + "Enable-NetFirewallRule -DisplayGroup 'Remote Desktop'" + ) + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", command], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + _write_log(f"RDP enable failed: {result.stderr.strip()}") + else: + _write_log("RDP enable applied (registry/service/firewall).") + except Exception as exc: + _write_log(f"RDP enable failed: {exc}") + + +class Role: + def __init__(self, ctx) -> None: + self.ctx = ctx + auto_enable = _bool_env("BOREALIS_RDP_AUTO_ENABLE") + _write_log(f"RDP role loaded auto_enable={auto_enable}") + if auto_enable and os.name == "nt": + _enable_rdp_windows() + + def register_events(self) -> None: + return + + def stop_all(self) -> None: + return diff --git a/Data/Engine/config.py b/Data/Engine/config.py index 78fa7711..dc883777 100644 --- a/Data/Engine/config.py +++ b/Data/Engine/config.py @@ -84,6 +84,11 @@ DEFAULT_WIREGUARD_PEER_NETWORK = "10.255.0.0/16" DEFAULT_WIREGUARD_SHELL_PORT = 47002 DEFAULT_WIREGUARD_ACL_WINDOWS = (3389, 5985, 5986, 5900, 3478, DEFAULT_WIREGUARD_SHELL_PORT) VPN_SERVER_CERT_ROOT = PROJECT_ROOT / "Engine" / "Certificates" / "VPN_Server" +DEFAULT_GUACD_HOST = "127.0.0.1" +DEFAULT_GUACD_PORT = 4822 +DEFAULT_RDP_WS_HOST = "0.0.0.0" +DEFAULT_RDP_WS_PORT = 4823 +DEFAULT_RDP_SESSION_TTL_SECONDS = 120 def _ensure_parent(path: Path) -> None: @@ -285,6 +290,11 @@ class EngineSettings: wireguard_server_public_key_path: str wireguard_acl_allowlist_windows: Tuple[int, ...] wireguard_shell_port: int + guacd_host: str + guacd_port: int + rdp_ws_host: str + rdp_ws_port: int + rdp_session_ttl_seconds: int raw: MutableMapping[str, Any] = field(default_factory=dict) def to_flask_config(self) -> MutableMapping[str, Any]: @@ -427,6 +437,36 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine wireguard_server_private_key_path = str(wireguard_key_root / "server_private.key") wireguard_server_public_key_path = str(wireguard_key_root / "server_public.key") + guacd_host = str( + runtime_config.get("GUACD_HOST") + or os.environ.get("BOREALIS_GUACD_HOST") + or DEFAULT_GUACD_HOST + ) + guacd_port = _parse_int( + runtime_config.get("GUACD_PORT") or os.environ.get("BOREALIS_GUACD_PORT"), + default=DEFAULT_GUACD_PORT, + minimum=1, + maximum=65535, + ) + rdp_ws_host = str( + runtime_config.get("RDP_WS_HOST") + or os.environ.get("BOREALIS_RDP_WS_HOST") + or DEFAULT_RDP_WS_HOST + ) + rdp_ws_port = _parse_int( + runtime_config.get("RDP_WS_PORT") or os.environ.get("BOREALIS_RDP_WS_PORT"), + default=DEFAULT_RDP_WS_PORT, + minimum=1, + maximum=65535, + ) + rdp_session_ttl_seconds = _parse_int( + runtime_config.get("RDP_SESSION_TTL_SECONDS") + or os.environ.get("BOREALIS_RDP_SESSION_TTL_SECONDS"), + default=DEFAULT_RDP_SESSION_TTL_SECONDS, + minimum=30, + maximum=3600, + ) + api_groups = _parse_api_groups( runtime_config.get("API_GROUPS") or os.environ.get("BOREALIS_API_GROUPS") ) @@ -465,6 +505,11 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine wireguard_server_public_key_path=wireguard_server_public_key_path, wireguard_acl_allowlist_windows=wireguard_acl_allowlist_windows, wireguard_shell_port=wireguard_shell_port, + guacd_host=guacd_host, + guacd_port=guacd_port, + rdp_ws_host=rdp_ws_host, + rdp_ws_port=rdp_ws_port, + rdp_session_ttl_seconds=rdp_session_ttl_seconds, raw=runtime_config, ) return settings diff --git a/Data/Engine/server.py b/Data/Engine/server.py index b6d45de3..f11cb605 100644 --- a/Data/Engine/server.py +++ b/Data/Engine/server.py @@ -128,8 +128,15 @@ class EngineContext: wireguard_server_public_key_path: str wireguard_acl_allowlist_windows: Tuple[int, ...] wireguard_shell_port: int + guacd_host: str + guacd_port: int + rdp_ws_host: str + rdp_ws_port: int + rdp_session_ttl_seconds: int wireguard_server_manager: Optional[Any] = None assembly_cache: Optional[Any] = None + rdp_proxy: Optional[Any] = None + rdp_registry: Optional[Any] = None __all__ = ["EngineContext", "create_app", "register_engine_api"] @@ -155,6 +162,11 @@ def _build_engine_context(settings: EngineSettings, logger: logging.Logger) -> E wireguard_server_public_key_path=settings.wireguard_server_public_key_path, wireguard_acl_allowlist_windows=settings.wireguard_acl_allowlist_windows, wireguard_shell_port=settings.wireguard_shell_port, + guacd_host=settings.guacd_host, + guacd_port=settings.guacd_port, + rdp_ws_host=settings.rdp_ws_host, + rdp_ws_port=settings.rdp_ws_port, + rdp_session_ttl_seconds=settings.rdp_session_ttl_seconds, assembly_cache=None, ) diff --git a/Data/Engine/services/API/__init__.py b/Data/Engine/services/API/__init__.py index 801187c6..cc840f02 100644 --- a/Data/Engine/services/API/__init__.py +++ b/Data/Engine/services/API/__init__.py @@ -33,6 +33,7 @@ from ..auth import DevModeManager from .enrollment import routes as enrollment_routes from .tokens import routes as token_routes from .devices.tunnel import register_tunnel +from .devices.rdp import register_rdp from ...server import EngineContext from .access_management.login import register_auth @@ -291,6 +292,7 @@ def _register_devices(app: Flask, adapters: EngineServiceAdapters) -> None: register_admin_endpoints(app, adapters) device_routes.register_agents(app, adapters) register_tunnel(app, adapters) + register_rdp(app, adapters) def _register_filters(app: Flask, adapters: EngineServiceAdapters) -> None: filters_management.register_filters(app, adapters) diff --git a/Data/Engine/services/API/devices/rdp.py b/Data/Engine/services/API/devices/rdp.py new file mode 100644 index 00000000..be917cd6 --- /dev/null +++ b/Data/Engine/services/API/devices/rdp.py @@ -0,0 +1,195 @@ +# ====================================================== +# Data\Engine\services\API\devices\rdp.py +# Description: RDP session bootstrap for Guacamole WebSocket tunnels. +# +# API Endpoints (if applicable): +# - POST /api/rdp/session (Token Authenticated) - Issues a one-time Guacamole tunnel token for RDP. +# ====================================================== + +"""RDP session bootstrap endpoints for the Borealis Engine.""" +from __future__ import annotations + +import os +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlsplit + +from flask import Blueprint, jsonify, request, session +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from ...RemoteDesktop.guacamole_proxy import GUAC_WS_PATH, ensure_guacamole_proxy +from .tunnel import _get_tunnel_service + +if False: # pragma: no cover - hint for type checkers + from .. import EngineServiceAdapters + + +def _current_user(app) -> Optional[Dict[str, str]]: + username = session.get("username") + role = session.get("role") or "User" + if username: + return {"username": username, "role": role} + + token = None + auth_header = request.headers.get("Authorization") or "" + if auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1].strip() + if not token: + token = request.cookies.get("borealis_auth") + if not token: + return None + + try: + serializer = URLSafeTimedSerializer(app.secret_key or "borealis-dev-secret", salt="borealis-auth") + token_ttl = int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30)) + data = serializer.loads(token, max_age=token_ttl) + username = data.get("u") + role = data.get("r") or "User" + if username: + return {"username": username, "role": role} + except (BadSignature, SignatureExpired, Exception): + return None + return None + + +def _require_login(app) -> Optional[Tuple[Dict[str, Any], int]]: + user = _current_user(app) + if not user: + return {"error": "unauthorized"}, 401 + return None + + +def _normalize_text(value: Any) -> str: + if value is None: + return "" + try: + return str(value).strip() + except Exception: + return "" + + +def _infer_endpoint_host(req) -> str: + forwarded = (req.headers.get("X-Forwarded-Host") or req.headers.get("X-Original-Host") or "").strip() + host = forwarded.split(",")[0].strip() if forwarded else (req.host or "").strip() + if not host: + return "" + try: + parsed = urlsplit(f"//{host}") + if parsed.hostname: + return parsed.hostname + except Exception: + return host + return host + + +def _is_secure(req) -> bool: + if req.is_secure: + return True + forwarded = (req.headers.get("X-Forwarded-Proto") or "").split(",")[0].strip().lower() + return forwarded == "https" + + +def register_rdp(app, adapters: "EngineServiceAdapters") -> None: + blueprint = Blueprint("rdp", __name__) + logger = adapters.context.logger.getChild("rdp.api") + service_log = adapters.service_log + + def _service_log_event(message: str, *, level: str = "INFO") -> None: + if not callable(service_log): + return + try: + service_log("RDP", message, level=level) + except Exception: + logger.debug("rdp service log write failed", exc_info=True) + + def _request_remote() -> str: + forwarded = (request.headers.get("X-Forwarded-For") or "").strip() + if forwarded: + return forwarded.split(",")[0].strip() + return (request.remote_addr or "").strip() + + @blueprint.route("/api/rdp/session", methods=["POST"]) + def rdp_session(): + requirement = _require_login(app) + if requirement: + payload, status = requirement + return jsonify(payload), status + + user = _current_user(app) or {} + operator_id = user.get("username") or None + + body = request.get_json(silent=True) or {} + agent_id = _normalize_text(body.get("agent_id")) + protocol = _normalize_text(body.get("protocol") or "rdp").lower() + username = _normalize_text(body.get("username")) + password = str(body.get("password") or "") + + if not agent_id: + return jsonify({"error": "agent_id_required"}), 400 + if protocol != "rdp": + return jsonify({"error": "unsupported_protocol"}), 400 + + tunnel_service = _get_tunnel_service(adapters) + session_payload = tunnel_service.session_payload(agent_id, include_token=False) + if not session_payload: + return jsonify({"error": "tunnel_down"}), 409 + + allowed_ports = session_payload.get("allowed_ports") or [] + if 3389 not in allowed_ports: + return jsonify({"error": "rdp_not_allowed"}), 403 + + virtual_ip = _normalize_text(session_payload.get("virtual_ip")) + host = virtual_ip.split("/")[0] if virtual_ip else "" + if not host: + return jsonify({"error": "virtual_ip_missing"}), 500 + + registry = ensure_guacamole_proxy(adapters.context, logger=logger) + if registry is None: + return jsonify({"error": "rdp_proxy_unavailable"}), 503 + + _service_log_event( + "rdp_session_request agent_id={0} operator={1} protocol={2} remote={3}".format( + agent_id, + operator_id or "-", + protocol, + _request_remote() or "-", + ) + ) + + rdp_session = registry.create( + agent_id=agent_id, + host=host, + port=3389, + username=username, + password=password, + protocol=protocol, + operator_id=operator_id, + ignore_cert=True, + ) + + ws_scheme = "wss" if _is_secure(request) else "ws" + ws_host = _infer_endpoint_host(request) + ws_port = int(getattr(adapters.context, "rdp_ws_port", 4823)) + ws_url = f"{ws_scheme}://{ws_host}:{ws_port}{GUAC_WS_PATH}" + + _service_log_event( + "rdp_session_ready agent_id={0} token={1} expires_at={2}".format( + agent_id, + rdp_session.token[:8], + int(rdp_session.expires_at), + ) + ) + + return ( + jsonify( + { + "token": rdp_session.token, + "ws_url": ws_url, + "expires_at": int(rdp_session.expires_at), + "virtual_ip": host, + "tunnel_id": session_payload.get("tunnel_id"), + } + ), + 200, + ) + + app.register_blueprint(blueprint) diff --git a/Data/Engine/services/RemoteDesktop/__init__.py b/Data/Engine/services/RemoteDesktop/__init__.py new file mode 100644 index 00000000..cf110fda --- /dev/null +++ b/Data/Engine/services/RemoteDesktop/__init__.py @@ -0,0 +1,9 @@ +# ====================================================== +# Data\Engine\services\RemoteDesktop\__init__.py +# Description: Remote desktop services (Guacamole proxy + session management). +# +# API Endpoints (if applicable): None +# ====================================================== + +"""Remote desktop service helpers for the Borealis Engine runtime.""" + diff --git a/Data/Engine/services/RemoteDesktop/guacamole_proxy.py b/Data/Engine/services/RemoteDesktop/guacamole_proxy.py new file mode 100644 index 00000000..dccebb1e --- /dev/null +++ b/Data/Engine/services/RemoteDesktop/guacamole_proxy.py @@ -0,0 +1,369 @@ +# ====================================================== +# Data\Engine\services\RemoteDesktop\guacamole_proxy.py +# Description: Guacamole tunnel proxy (WebSocket -> guacd) for RDP sessions. +# +# API Endpoints (if applicable): None +# ====================================================== + +"""Guacamole WebSocket proxy that bridges browser tunnels to guacd.""" +from __future__ import annotations + +import asyncio +import logging +import ssl +import threading +import time +import uuid +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple +from urllib.parse import parse_qs, urlsplit + +import websockets + +GUAC_WS_PATH = "/guacamole" +_MAX_MESSAGE_SIZE = 100_000_000 + + +@dataclass +class RdpSession: + token: str + agent_id: str + host: str + port: int + protocol: str + username: str + password: str + ignore_cert: bool + created_at: float + expires_at: float + operator_id: Optional[str] = None + domain: Optional[str] = None + security: Optional[str] = None + + +class RdpSessionRegistry: + def __init__(self, ttl_seconds: int, logger: logging.Logger) -> None: + self.ttl_seconds = max(30, int(ttl_seconds)) + self.logger = logger + self._lock = threading.Lock() + self._sessions: Dict[str, RdpSession] = {} + + def _cleanup(self, now: Optional[float] = None) -> None: + current = now if now is not None else time.time() + expired = [token for token, session in self._sessions.items() if session.expires_at <= current] + for token in expired: + self._sessions.pop(token, None) + + def create( + self, + *, + agent_id: str, + host: str, + port: int, + username: str, + password: str, + protocol: str = "rdp", + ignore_cert: bool = True, + operator_id: Optional[str] = None, + domain: Optional[str] = None, + security: Optional[str] = None, + ) -> RdpSession: + token = uuid.uuid4().hex + now = time.time() + expires_at = now + self.ttl_seconds + session = RdpSession( + token=token, + agent_id=agent_id, + host=host, + port=port, + protocol=protocol, + username=username, + password=password, + ignore_cert=ignore_cert, + created_at=now, + expires_at=expires_at, + operator_id=operator_id, + domain=domain, + security=security, + ) + with self._lock: + self._cleanup(now) + self._sessions[token] = session + return session + + def consume(self, token: str) -> Optional[RdpSession]: + if not token: + return None + with self._lock: + self._cleanup() + session = self._sessions.pop(token, None) + return session + + +class GuacamoleProxyServer: + def __init__( + self, + *, + host: str, + port: int, + guacd_host: str, + guacd_port: int, + registry: RdpSessionRegistry, + logger: logging.Logger, + ssl_context: Optional[ssl.SSLContext] = None, + ) -> None: + self.host = host + self.port = port + self.guacd_host = guacd_host + self.guacd_port = guacd_port + self.registry = registry + self.logger = logger + self.ssl_context = ssl_context + self._thread: Optional[threading.Thread] = None + self._ready = threading.Event() + self._failed = threading.Event() + + def ensure_started(self, timeout: float = 3.0) -> bool: + if self._thread and self._thread.is_alive(): + return not self._failed.is_set() + self._failed.clear() + self._ready.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + self._ready.wait(timeout) + return not self._failed.is_set() + + def _run(self) -> None: + try: + asyncio.run(self._serve()) + except Exception as exc: + self._failed.set() + self.logger.error("Guacamole proxy server failed: %s", exc) + self._ready.set() + + async def _serve(self) -> None: + self.logger.info( + "Starting Guacamole proxy on %s:%s (guacd %s:%s)", + self.host, + self.port, + self.guacd_host, + self.guacd_port, + ) + try: + server = await websockets.serve( + self._handle_client, + self.host, + self.port, + ssl=self.ssl_context, + max_size=_MAX_MESSAGE_SIZE, + ping_interval=20, + ping_timeout=20, + ) + except Exception: + self._failed.set() + self._ready.set() + raise + self._ready.set() + await server.wait_closed() + + async def _handle_client(self, websocket, path: str) -> None: + parsed = urlsplit(path) + if parsed.path != GUAC_WS_PATH: + await websocket.close(code=1008, reason="invalid_path") + return + query = parse_qs(parsed.query or "") + token = (query.get("token") or [""])[0] + session = self.registry.consume(token) + if not session: + await websocket.close(code=1008, reason="invalid_session") + return + + logger = self.logger.getChild("session") + logger.info("Guacamole session start agent_id=%s protocol=%s", session.agent_id, session.protocol) + + try: + reader, writer = await asyncio.open_connection(self.guacd_host, self.guacd_port) + except Exception as exc: + logger.warning("guacd connect failed: %s", exc) + await websocket.close(code=1011, reason="guacd_unavailable") + return + + try: + await self._perform_handshake(reader, writer, session) + except Exception as exc: + logger.warning("guacd handshake failed: %s", exc) + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + await websocket.close(code=1011, reason="handshake_failed") + return + + async def _ws_to_guacd() -> None: + try: + async for message in websocket: + if message is None: + break + if isinstance(message, str): + data = message.encode("utf-8") + else: + data = bytes(message) + writer.write(data) + await writer.drain() + finally: + try: + writer.close() + except Exception: + pass + + async def _guacd_to_ws() -> None: + try: + while True: + data = await reader.read(8192) + if not data: + break + await websocket.send(data.decode("utf-8", errors="ignore")) + finally: + try: + await websocket.close() + except Exception: + pass + + await asyncio.wait( + [asyncio.create_task(_ws_to_guacd()), asyncio.create_task(_guacd_to_ws())], + return_when=asyncio.FIRST_COMPLETED, + ) + + logger.info("Guacamole session ended agent_id=%s", session.agent_id) + + async def _perform_handshake(self, reader, writer, session: RdpSession) -> None: + writer.write(_encode_instruction("select", session.protocol)) + await writer.drain() + + buffer = b"" + args = None + deadline = time.time() + 8 + + while time.time() < deadline: + parts, buffer = await _read_instruction(reader, buffer) + if not parts: + continue + op = parts[0] + if op == "args": + args = parts[1:] + break + if op == "error": + raise RuntimeError("guacd_error:" + " ".join(parts[1:])) + if not args: + raise RuntimeError("guacd_args_timeout") + + params = { + "hostname": session.host, + "port": str(session.port), + "username": session.username or "", + "password": session.password or "", + } + if session.domain: + params["domain"] = session.domain + if session.security: + params["security"] = session.security + if session.ignore_cert: + params["ignore-cert"] = "true" + + values = [params.get(name, "") for name in args] + writer.write(_encode_instruction("connect", *values)) + await writer.drain() + + +def _encode_instruction(*elements: str) -> bytes: + parts = [] + for element in elements: + text = "" if element is None else str(element) + parts.append(f"{len(text)}.{text}".encode("utf-8")) + return b",".join(parts) + b";" + + +def _parse_instruction(raw: bytes) -> Tuple[str, ...]: + parts = [] + idx = 0 + length = len(raw) + while idx < length: + dot = raw.find(b".", idx) + if dot < 0: + break + try: + element_len = int(raw[idx:dot].decode("ascii") or "0") + except Exception: + break + start = dot + 1 + end = start + element_len + if end > length: + break + parts.append(raw[start:end].decode("utf-8", errors="ignore")) + idx = end + if idx < length and raw[idx:idx + 1] == b",": + idx += 1 + return tuple(parts) + + +async def _read_instruction(reader, buffer: bytes) -> Tuple[Tuple[str, ...], bytes]: + while b";" not in buffer: + chunk = await reader.read(4096) + if not chunk: + break + buffer += chunk + if b";" not in buffer: + return tuple(), buffer + instruction, remainder = buffer.split(b";", 1) + if not instruction: + return tuple(), remainder + return _parse_instruction(instruction), remainder + + +def _build_ssl_context(cert_path: Optional[str], key_path: Optional[str]) -> Optional[ssl.SSLContext]: + if not cert_path or not key_path: + return None + try: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=cert_path, keyfile=key_path) + return context + except Exception: + return None + + +def ensure_guacamole_proxy(context: Any, *, logger: Optional[logging.Logger] = None) -> Optional[RdpSessionRegistry]: + if logger is None: + logger = context.logger if hasattr(context, "logger") else logging.getLogger("borealis.engine.rdp") + + registry = getattr(context, "rdp_registry", None) + if registry is None: + ttl = int(getattr(context, "rdp_session_ttl_seconds", 120)) + registry = RdpSessionRegistry(ttl_seconds=ttl, logger=logger) + setattr(context, "rdp_registry", registry) + + proxy = getattr(context, "rdp_proxy", None) + if proxy is None: + cert_path = getattr(context, "tls_bundle_path", None) or getattr(context, "tls_cert_path", None) + ssl_context = _build_ssl_context( + cert_path, + getattr(context, "tls_key_path", None), + ) + proxy = GuacamoleProxyServer( + host=str(getattr(context, "rdp_ws_host", "0.0.0.0")), + port=int(getattr(context, "rdp_ws_port", 4823)), + guacd_host=str(getattr(context, "guacd_host", "127.0.0.1")), + guacd_port=int(getattr(context, "guacd_port", 4822)), + registry=registry, + logger=logger.getChild("guacamole_proxy"), + ssl_context=ssl_context, + ) + setattr(context, "rdp_proxy", proxy) + + if not proxy.ensure_started(): + logger.error("Guacamole proxy failed to start; RDP sessions unavailable.") + return None + return registry + + +__all__ = ["GUAC_WS_PATH", "RdpSessionRegistry", "GuacamoleProxyServer", "ensure_guacamole_proxy"] diff --git a/Data/Engine/web-interface/package.json b/Data/Engine/web-interface/package.json index 49cffbb9..4d4b3ef9 100644 --- a/Data/Engine/web-interface/package.json +++ b/Data/Engine/web-interface/package.json @@ -19,6 +19,7 @@ "ag-grid-community": "34.2.0", "ag-grid-react": "34.2.0", "dayjs": "1.11.18", + "guacamole-common-js": "1.5.0", "normalize.css": "8.0.1", "prismjs": "1.30.0", "react-simple-code-editor": "0.13.1", diff --git a/Data/Engine/web-interface/src/Devices/Device_Details.jsx b/Data/Engine/web-interface/src/Devices/Device_Details.jsx index f2012111..00418d29 100644 --- a/Data/Engine/web-interface/src/Devices/Device_Details.jsx +++ b/Data/Engine/web-interface/src/Devices/Device_Details.jsx @@ -27,6 +27,7 @@ import LanRoundedIcon from "@mui/icons-material/LanRounded"; import AppsRoundedIcon from "@mui/icons-material/AppsRounded"; import ListAltRoundedIcon from "@mui/icons-material/ListAltRounded"; import TerminalRoundedIcon from "@mui/icons-material/TerminalRounded"; +import DesktopWindowsRoundedIcon from "@mui/icons-material/DesktopWindowsRounded"; import TuneRoundedIcon from "@mui/icons-material/TuneRounded"; import SpeedRoundedIcon from "@mui/icons-material/SpeedRounded"; import DeveloperBoardRoundedIcon from "@mui/icons-material/DeveloperBoardRounded"; @@ -42,6 +43,7 @@ import Editor from "react-simple-code-editor"; import { AgGridReact } from "ag-grid-react"; import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; import ReverseTunnelPowershell from "./ReverseTunnel/Powershell.jsx"; +import ReverseTunnelRdp from "./ReverseTunnel/RDP.jsx"; ModuleRegistry.registerModules([AllCommunityModule]); @@ -119,6 +121,7 @@ const TOP_TABS = [ { key: "activity", label: "Activity History", icon: ListAltRoundedIcon }, { key: "advanced", label: "Advanced Config", icon: TuneRoundedIcon }, { key: "shell", label: "Remote Shell", icon: TerminalRoundedIcon }, + { key: "rdp", label: "Remote Desktop", icon: DesktopWindowsRoundedIcon }, ]; const myTheme = themeQuartz.withParams({ @@ -1530,6 +1533,19 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage ); + const renderRemoteDesktopTab = () => ( + + + + ); + const handleVpnToggle = useCallback((key, checked) => { setVpnToggles((prev) => ({ ...(prev || {}), [key]: checked })); setVpnSource("custom"); @@ -1926,6 +1942,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage renderHistory, renderAdvancedConfigTab, renderRemoteShellTab, + renderRemoteDesktopTab, ]; const tabContent = (topTabRenderers[tab] || renderDeviceSummaryTab)(); diff --git a/Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx b/Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx new file mode 100644 index 00000000..816f5c5a --- /dev/null +++ b/Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx @@ -0,0 +1,552 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + Button, + Chip, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, + LinearProgress, +} from "@mui/material"; +import { + DesktopWindowsRounded as DesktopIcon, + PlayArrowRounded as PlayIcon, + StopRounded as StopIcon, + LinkRounded as LinkIcon, + LanRounded as IpIcon, +} from "@mui/icons-material"; +import Guacamole from "guacamole-common-js"; + +const MAGIC_UI = { + panelBorder: "rgba(148, 163, 184, 0.35)", + textMuted: "#94a3b8", + textBright: "#e2e8f0", + accentA: "#7dd3fc", + accentB: "#c084fc", + accentC: "#34d399", +}; + +const gradientButtonSx = { + backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)", + color: "#0b1220", + borderRadius: 999, + textTransform: "none", + px: 2.2, + minWidth: 120, + "&:hover": { + backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)", + }, +}; + +const PROTOCOLS = [{ value: "rdp", label: "RDP" }]; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function normalizeText(value) { + if (value == null) return ""; + try { + return String(value).trim(); + } catch { + return ""; + } +} + +export default function ReverseTunnelRdp({ device }) { + const [sessionState, setSessionState] = useState("idle"); + const [statusMessage, setStatusMessage] = useState(""); + const [loading, setLoading] = useState(false); + const [tunnel, setTunnel] = useState(null); + const [protocol, setProtocol] = useState("rdp"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const containerRef = useRef(null); + const displayRef = useRef(null); + const clientRef = useRef(null); + const tunnelRef = useRef(null); + const mouseRef = useRef(null); + const agentIdRef = useRef(""); + const tunnelIdRef = useRef(""); + const bumpTimerRef = useRef(null); + + const agentId = useMemo(() => { + return ( + normalizeText(device?.agent_id) || + normalizeText(device?.agentId) || + normalizeText(device?.agent_guid) || + normalizeText(device?.agentGuid) || + normalizeText(device?.id) || + normalizeText(device?.guid) || + normalizeText(device?.summary?.agent_id) || + "" + ); + }, [device]); + + useEffect(() => { + agentIdRef.current = agentId; + }, [agentId]); + + useEffect(() => { + tunnelIdRef.current = tunnel?.tunnel_id || ""; + }, [tunnel?.tunnel_id]); + + const notifyAgentOnboarding = useCallback(async () => { + try { + await fetch("/api/notifications/notify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title: "Agent Onboarding Underway", + message: + "Please wait for the agent to finish onboarding into Borealis. It takes about 1 minute to finish the process.", + icon: "info", + variant: "info", + }), + }); + } catch { + /* ignore notification transport errors */ + } + }, []); + + const handleAgentOnboarding = useCallback(async () => { + await notifyAgentOnboarding(); + setStatusMessage("Agent Onboarding Underway."); + setSessionState("idle"); + setTunnel(null); + }, [notifyAgentOnboarding]); + + const teardownDisplay = useCallback(() => { + try { + const client = clientRef.current; + if (client) { + client.disconnect(); + } + } catch { + /* ignore */ + } + clientRef.current = null; + tunnelRef.current = null; + mouseRef.current = null; + const host = displayRef.current; + if (host) { + host.innerHTML = ""; + } + }, []); + + const stopTunnel = useCallback(async (reason = "operator_disconnect") => { + const currentAgentId = agentIdRef.current; + if (!currentAgentId) return; + const currentTunnelId = tunnelIdRef.current; + try { + await fetch("/api/tunnel/disconnect", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: currentAgentId, tunnel_id: currentTunnelId, reason }), + }); + } catch { + // best-effort + } + }, []); + + const clearBumpTimer = useCallback(() => { + if (bumpTimerRef.current) { + clearInterval(bumpTimerRef.current); + bumpTimerRef.current = null; + } + }, []); + + const startBumpTimer = useCallback( + (currentAgentId) => { + clearBumpTimer(); + if (!currentAgentId) return; + bumpTimerRef.current = setInterval(async () => { + try { + await fetch(`/api/tunnel/connect/status?agent_id=${encodeURIComponent(currentAgentId)}&bump=1`); + } catch { + /* ignore */ + } + }, 60000); + }, + [clearBumpTimer] + ); + + const handleDisconnect = useCallback(async () => { + setLoading(true); + setStatusMessage(""); + clearBumpTimer(); + try { + teardownDisplay(); + await stopTunnel("operator_disconnect"); + } finally { + setTunnel(null); + setSessionState("idle"); + setLoading(false); + } + }, [clearBumpTimer, stopTunnel, teardownDisplay]); + + const scaleToFit = useCallback(() => { + const client = clientRef.current; + const container = containerRef.current; + if (!client || !container) return; + const display = client.getDisplay(); + const displayWidth = display.getWidth(); + const displayHeight = display.getHeight(); + const bounds = container.getBoundingClientRect(); + if (!displayWidth || !displayHeight || !bounds.width || !bounds.height) return; + const scale = Math.min(bounds.width / displayWidth, bounds.height / displayHeight); + display.scale(scale); + }, []); + + useEffect(() => { + const handleResize = () => scaleToFit(); + window.addEventListener("resize", handleResize); + let observer = null; + if (typeof ResizeObserver !== "undefined" && containerRef.current) { + observer = new ResizeObserver(() => scaleToFit()); + observer.observe(containerRef.current); + } + return () => { + window.removeEventListener("resize", handleResize); + if (observer) observer.disconnect(); + }; + }, [scaleToFit]); + + useEffect(() => { + return () => { + clearBumpTimer(); + teardownDisplay(); + stopTunnel("component_unmount"); + }; + }, [clearBumpTimer, stopTunnel, teardownDisplay]); + + const requestTunnel = useCallback(async () => { + if (!agentId) { + setStatusMessage("Agent ID is required to connect."); + return null; + } + setLoading(true); + setStatusMessage(""); + try { + try { + const readinessResp = await fetch(`/api/tunnel/status?agent_id=${encodeURIComponent(agentId)}`); + const readinessData = await readinessResp.json().catch(() => ({})); + if (readinessResp.ok && readinessData?.agent_socket !== true) { + await handleAgentOnboarding(); + return null; + } + } catch { + // best-effort readiness check + } + + setSessionState("connecting"); + const resp = await fetch("/api/tunnel/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: agentId }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const detail = data?.detail ? `: ${data.detail}` : ""; + throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`); + } + tunnelIdRef.current = data?.tunnel_id || ""; + + const waitForTunnelReady = async () => { + const deadline = Date.now() + 60000; + let lastError = ""; + while (Date.now() < deadline) { + const statusResp = await fetch( + `/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1` + ); + const statusData = await statusResp.json().catch(() => ({})); + if (statusData?.error === "agent_socket_missing" || (statusResp.ok && statusData?.agent_socket === false)) { + await handleAgentOnboarding(); + await stopTunnel("agent_onboarding_pending"); + return null; + } + if (statusResp.ok && statusData?.status === "up") { + const agentSocket = statusData?.agent_socket; + const agentReady = agentSocket === undefined ? true : Boolean(agentSocket); + if (agentReady) { + return statusData; + } + setStatusMessage("Waiting for agent VPN socket to register..."); + } else if (statusData?.error) { + lastError = statusData.error; + } + await sleep(2000); + } + throw new Error(lastError || "Tunnel not ready"); + }; + + const statusData = await waitForTunnelReady(); + if (!statusData) { + return null; + } + setTunnel({ ...data, ...statusData }); + startBumpTimer(agentId); + return statusData; + } catch (err) { + setSessionState("error"); + setStatusMessage(String(err.message || err)); + return null; + } finally { + setLoading(false); + } + }, [agentId, handleAgentOnboarding, startBumpTimer, stopTunnel]); + + const openRdpSession = useCallback( + async () => { + const currentAgentId = agentIdRef.current; + if (!currentAgentId) return; + const payload = { + agent_id: currentAgentId, + protocol, + username: username.trim(), + password, + }; + const resp = await fetch("/api/rdp/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const detail = data?.detail ? `: ${data.detail}` : ""; + throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`); + } + const token = data?.token; + const wsUrl = data?.ws_url; + if (!token || !wsUrl) { + throw new Error("RDP session unavailable."); + } + const tunnelUrl = `${wsUrl}?token=${encodeURIComponent(token)}`; + const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl); + const client = new Guacamole.Client(tunnel); + const displayHost = displayRef.current; + + tunnel.onerror = (status) => { + setStatusMessage(status?.message || "RDP tunnel error."); + }; + client.onerror = (status) => { + setStatusMessage(status?.message || "RDP client error."); + }; + client.onstatechange = (state) => { + if (state === Guacamole.Client.State.CONNECTED) { + setSessionState("connected"); + setStatusMessage(""); + } else if (state === Guacamole.Client.State.DISCONNECTED) { + setSessionState("idle"); + } + }; + client.onresize = () => { + scaleToFit(); + }; + + if (displayHost) { + displayHost.innerHTML = ""; + displayHost.appendChild(client.getDisplay().getElement()); + } + + const mouse = new Guacamole.Mouse(client.getDisplay().getElement()); + mouse.onmousemove = (state) => client.sendMouseState(state); + mouse.onmousedown = (state) => client.sendMouseState(state); + mouse.onmouseup = (state) => client.sendMouseState(state); + + clientRef.current = client; + tunnelRef.current = tunnel; + mouseRef.current = mouse; + client.connect(); + scaleToFit(); + setStatusMessage("Connecting to RDP..."); + }, + [password, protocol, scaleToFit, username] + ); + + const handleConnect = useCallback(async () => { + if (sessionState === "connected") return; + setStatusMessage(""); + setSessionState("connecting"); + try { + const tunnelReady = await requestTunnel(); + if (!tunnelReady) { + return; + } + await openRdpSession(); + } catch (err) { + setSessionState("error"); + setStatusMessage(String(err.message || err)); + } + }, [openRdpSession, requestTunnel, sessionState]); + + const isConnected = sessionState === "connected"; + const sessionChips = [ + tunnel?.tunnel_id + ? { + label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`, + color: MAGIC_UI.accentB, + icon: , + } + : null, + tunnel?.virtual_ip + ? { + label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`, + color: MAGIC_UI.accentA, + icon: , + } + : null, + ].filter(Boolean); + + return ( + + + + + + Protocol + + + setUsername(e.target.value)} + disabled={isConnected} + sx={{ + minWidth: 180, + input: { color: MAGIC_UI.textBright }, + "& .MuiOutlinedInput-root": { + backgroundColor: "rgba(12,18,35,0.9)", + borderRadius: 2, + "& fieldset": { borderColor: "rgba(148,163,184,0.45)" }, + "&:hover fieldset": { borderColor: MAGIC_UI.accentA }, + }, + "& .MuiInputLabel-root": { color: MAGIC_UI.textMuted }, + }} + /> + setPassword(e.target.value)} + disabled={isConnected} + sx={{ + minWidth: 180, + input: { color: MAGIC_UI.textBright }, + "& .MuiOutlinedInput-root": { + backgroundColor: "rgba(12,18,35,0.9)", + borderRadius: 2, + "& fieldset": { borderColor: "rgba(148,163,184,0.45)" }, + "&:hover fieldset": { borderColor: MAGIC_UI.accentA }, + }, + "& .MuiInputLabel-root": { color: MAGIC_UI.textMuted }, + }} + /> + + + {sessionChips.map((chip) => ( + + ))} + + + + + {loading ? : null} + + + {!isConnected ? ( + + + + Connect to start the remote desktop session. + + + ) : null} + + + + + + Session: {isConnected ? "Active" : sessionState} + + {statusMessage ? ( + + {statusMessage} + + ) : null} + + + ); +}