mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2026-02-04 06:50:31 -07:00
Initial RDP Implementation
This commit is contained in:
77
Data/Agent/Roles/role_RDP.py
Normal file
77
Data/Agent/Roles/role_RDP.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
195
Data/Engine/services/API/devices/rdp.py
Normal file
195
Data/Engine/services/API/devices/rdp.py
Normal 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)
|
||||
9
Data/Engine/services/RemoteDesktop/__init__.py
Normal file
9
Data/Engine/services/RemoteDesktop/__init__.py
Normal 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."""
|
||||
|
||||
369
Data/Engine/services/RemoteDesktop/guacamole_proxy.py
Normal file
369
Data/Engine/services/RemoteDesktop/guacamole_proxy.py
Normal 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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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)();
|
||||
|
||||
|
||||
552
Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx
Normal file
552
Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user