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 (
+
+
+ : }
+ sx={gradientButtonSx}
+ disabled={loading || (!isConnected && !agentId)}
+ onClick={isConnected ? handleDisconnect : handleConnect}
+ >
+ {isConnected ? "Disconnect" : "Connect"}
+
+
+
+ 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}
+
+
+ );
+}