Initial RDP Implementation

This commit is contained in:
2026-01-15 23:51:17 -07:00
parent bf7cbf6b7f
commit 35f26ce4ee
10 changed files with 1279 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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"]

View File

@@ -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",

View File

@@ -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
</Box>
);
const renderRemoteDesktopTab = () => (
<Box
sx={{
display: "flex",
flexDirection: "column",
flexGrow: 1,
minHeight: 0,
}}
>
<ReverseTunnelRdp device={tunnelDevice} />
</Box>
);
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)();

View File

@@ -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: <LinkIcon sx={{ fontSize: 18 }} />,
}
: null,
tunnel?.virtual_ip
? {
label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
color: MAGIC_UI.accentA,
icon: <IpIcon sx={{ fontSize: 18 }} />,
}
: null,
].filter(Boolean);
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} alignItems={{ xs: "flex-start", md: "center" }}>
<Button
size="small"
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
sx={gradientButtonSx}
disabled={loading || (!isConnected && !agentId)}
onClick={isConnected ? handleDisconnect : handleConnect}
>
{isConnected ? "Disconnect" : "Connect"}
</Button>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", alignItems: "center" }}>
<FormControl
size="small"
sx={{
minWidth: 140,
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(12,18,35,0.9)",
color: MAGIC_UI.textBright,
borderRadius: 2,
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}}
>
<InputLabel>Protocol</InputLabel>
<Select
label="Protocol"
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
disabled={isConnected}
>
{PROTOCOLS.map((item) => (
<MenuItem key={item.value} value={item.value}>
{item.label}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
label="Username"
value={username}
onChange={(e) => 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 },
}}
/>
<TextField
size="small"
type="password"
label="Password"
value={password}
onChange={(e) => 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 },
}}
/>
</Stack>
<Stack direction="row" spacing={1}>
{sessionChips.map((chip) => (
<Chip
key={chip.label}
icon={chip.icon}
label={chip.label}
sx={{
borderRadius: 999,
color: chip.color,
border: `1px solid ${MAGIC_UI.panelBorder}`,
backgroundColor: "rgba(8,12,24,0.65)",
}}
/>
))}
</Stack>
</Stack>
<Box
sx={{
flexGrow: 1,
minHeight: 320,
display: "flex",
flexDirection: "column",
borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`,
background:
"linear-gradient(145deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 20%, rgba(125,211,252,0.08), transparent 35%)",
boxShadow: "0 25px 80px rgba(2,6,23,0.85)",
overflow: "hidden",
position: "relative",
}}
>
{loading ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
<Box
ref={containerRef}
sx={{
flexGrow: 1,
position: "relative",
backgroundColor: "rgba(2,6,20,0.9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Box ref={displayRef} sx={{ width: "100%", height: "100%" }} />
{!isConnected ? (
<Stack spacing={1} sx={{ position: "absolute", alignItems: "center" }}>
<DesktopIcon sx={{ color: MAGIC_UI.accentA, fontSize: 40 }} />
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Connect to start the remote desktop session.
</Typography>
</Stack>
) : null}
</Box>
</Box>
<Stack spacing={0.3} sx={{ mt: 1 }}>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Session: {isConnected ? "Active" : sessionState}
</Typography>
{statusMessage ? (
<Typography variant="body2" sx={{ color: sessionState === "error" ? "#ff7b89" : MAGIC_UI.textMuted }}>
{statusMessage}
</Typography>
) : null}
</Stack>
</Box>
);
}