diff --git a/Data/Agent/Roles/role_VNC.py b/Data/Agent/Roles/role_VNC.py index 42cfab5e..676746fb 100644 --- a/Data/Agent/Roles/role_VNC.py +++ b/Data/Agent/Roles/role_VNC.py @@ -61,12 +61,44 @@ def _find_project_root() -> Optional[Path]: def _resolve_vnc_root() -> Optional[Path]: + override = os.environ.get("BOREALIS_VNC_ROOT") or os.environ.get("BOREALIS_ULTRAVNC_ROOT") + if override: + try: + override_path = Path(override).expanduser().resolve() + if override_path.is_dir(): + return override_path + except Exception: + pass root = _find_project_root() - if not root: - return None - candidate = root / "Dependencies" / "UltraVNC_Server" - if candidate.is_dir(): - return candidate + candidates: list[Path] = [] + if root: + candidates.append(root / "Dependencies" / "UltraVNC_Server") + candidates.append(root / "UltraVNC_Server") + try: + current = Path(__file__).resolve() + for parent in (current, *current.parents): + candidates.append(parent / "Dependencies" / "UltraVNC_Server") + candidates.append(parent / "UltraVNC_Server") + except Exception: + pass + try: + cwd = Path.cwd().resolve() + for parent in (cwd, *cwd.parents): + candidates.append(parent / "Dependencies" / "UltraVNC_Server") + candidates.append(parent / "UltraVNC_Server") + except Exception: + pass + seen = set() + for candidate in candidates: + try: + resolved = candidate.resolve() + except Exception: + resolved = candidate + if resolved in seen: + continue + seen.add(resolved) + if candidate.is_dir(): + return candidate return None @@ -137,20 +169,46 @@ def _resolve_vnc_password_tool(root: Optional[Path]) -> Optional[str]: vnc_root / "tools" / "createpassword.exe", vnc_root / "createpassword64.exe", vnc_root / "tools" / "createpassword64.exe", + vnc_root / "payload" / "x64" / "createpassword.exe", + vnc_root / "payload" / "x64" / "createpassword64.exe", ] ) for candidate in candidates: if candidate.is_file(): return str(candidate) try: - for candidate in root.rglob("createpassword.exe"): - if candidate.is_file(): - return str(candidate) + if root: + for candidate in root.rglob("createpassword.exe"): + if candidate.is_file(): + return str(candidate) except Exception: pass return None +def _discover_ultravnc_service_name() -> Optional[str]: + if os.name != "nt": + return None + command = ( + "Get-Service -ErrorAction SilentlyContinue | " + "Where-Object { $_.Name -like '*uvnc*' -or $_.DisplayName -like '*UltraVNC*' } | " + "Select-Object -First 1 -ExpandProperty Name" + ) + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", command], + capture_output=True, + text=True, + check=False, + ) + output = (result.stdout or "").strip() + if output: + return output.splitlines()[0].strip() + except Exception: + return None + return None + + def _ensure_ultravnc_ini(config_dir: Path, port: int) -> Optional[Path]: try: config_dir.mkdir(parents=True, exist_ok=True) @@ -195,13 +253,14 @@ class VncManager: self._last_password: Optional[str] = None self._vnc_exe = _resolve_vnc_exe() self._password_tool: Optional[str] = None + self._service_name: Optional[str] = None - def _service_state(self) -> Optional[str]: + def _service_state_by_name(self, service_name: str) -> Optional[str]: if os.name != "nt": return None try: result = subprocess.run( - ["sc.exe", "query", ULTRAVNC_SERVICE_NAME], + ["sc.exe", "query", service_name], capture_output=True, text=True, check=False, @@ -217,25 +276,62 @@ class VncManager: return None return None + def _resolve_service_name(self, *, refresh: bool = False) -> Optional[str]: + if self._service_name and not refresh: + return self._service_name + candidate = ULTRAVNC_SERVICE_NAME + if candidate: + state = self._service_state_by_name(candidate) + if state is not None: + self._service_name = candidate + return candidate + discovered = _discover_ultravnc_service_name() + if discovered: + self._service_name = discovered + return discovered + if candidate: + self._service_name = candidate + return candidate + return None + + def _wait_for_service(self, service_name: str, timeout: float = 5.0) -> bool: + deadline = time.time() + max(1.0, timeout) + while time.time() < deadline: + state = self._service_state_by_name(service_name) + if state == "RUNNING": + return True + if state is None: + return False + time.sleep(0.5) + return False + def _restart_service(self) -> None: if os.name != "nt": return - state = self._service_state() + service_name = self._resolve_service_name() + if not service_name: + return + state = self._service_state_by_name(service_name) if state != "RUNNING": return try: - subprocess.run(["sc.exe", "stop", ULTRAVNC_SERVICE_NAME], capture_output=True, text=True, check=False) + subprocess.run(["sc.exe", "stop", service_name], capture_output=True, text=True, check=False) time.sleep(1) - subprocess.run(["sc.exe", "start", ULTRAVNC_SERVICE_NAME], capture_output=True, text=True, check=False) + subprocess.run(["sc.exe", "start", service_name], capture_output=True, text=True, check=False) + if not self._wait_for_service(service_name, timeout=8.0): + _write_log(f"UltraVNC service restart timed out (service={service_name}).") except Exception as exc: _write_log(f"Failed to restart UltraVNC service: {exc}") def _ensure_service_running(self) -> bool: if os.name != "nt": return False - state = self._service_state() + service_name = self._resolve_service_name() + state = self._service_state_by_name(service_name) if service_name else None if state == "RUNNING": return True + if state == "START_PENDING" and service_name: + return self._wait_for_service(service_name, timeout=10.0) if not self._vnc_exe: self._vnc_exe = _resolve_vnc_exe() if not self._vnc_exe: @@ -243,17 +339,28 @@ class VncManager: try: if state is None: subprocess.run([self._vnc_exe, "-install"], capture_output=True, text=True, check=False) + service_name = self._resolve_service_name(refresh=True) + if not service_name: + service_name = ULTRAVNC_SERVICE_NAME subprocess.run( - ["sc.exe", "config", ULTRAVNC_SERVICE_NAME, "start=", "auto"], + ["sc.exe", "config", service_name, "start=", "auto"], capture_output=True, text=True, check=False, ) - subprocess.run(["sc.exe", "start", ULTRAVNC_SERVICE_NAME], capture_output=True, text=True, check=False) + start_result = subprocess.run( + ["sc.exe", "start", service_name], + capture_output=True, + text=True, + check=False, + ) + start_output = (start_result.stdout or "") + (start_result.stderr or "") + if "SERVICE_ALREADY_RUNNING" in start_output: + return True except Exception as exc: _write_log(f"Failed to ensure UltraVNC service running: {exc}") return False - return self._service_state() == "RUNNING" + return self._wait_for_service(service_name, timeout=10.0) def _normalize_firewall_remote(self, allowed_ips: Optional[str]) -> Optional[str]: if not allowed_ips: @@ -321,7 +428,10 @@ class VncManager: if not self._password_tool: self._password_tool = _resolve_vnc_password_tool(config_dir) if not self._password_tool: - _write_log("VNC password tool not found; expected createpassword.exe under Dependencies/UltraVNC_Server.") + _write_log( + "VNC password tool not found; expected createpassword.exe under " + "Dependencies/UltraVNC_Server/tools or payload." + ) return None try: result = subprocess.run( @@ -354,7 +464,10 @@ class VncManager: if not self._vnc_exe: self._vnc_exe = _resolve_vnc_exe() if not self._vnc_exe: - _write_log("UltraVNC server binary not found; expected under Dependencies/UltraVNC_Server.") + _write_log( + "UltraVNC server binary not found; expected under " + "Dependencies/UltraVNC_Server/payload (or set BOREALIS_VNC_SERVER_BIN)." + ) return exe_path = Path(self._vnc_exe) diff --git a/Data/Engine/services/API/server/info.py b/Data/Engine/services/API/server/info.py index 1831eb98..38cd05e9 100644 --- a/Data/Engine/services/API/server/info.py +++ b/Data/Engine/services/API/server/info.py @@ -4,16 +4,19 @@ # # API Endpoints (if applicable): # - GET /api/server/time (Operator Session) - Returns the server clock in multiple formats. +# - GET /api/server/certificates/root (Operator Session) - Downloads the Borealis root CA certificate. # ====================================================== from __future__ import annotations from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Dict +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Optional -from flask import Blueprint, Flask, jsonify +from flask import Blueprint, Flask, jsonify, send_file from ...auth import RequestAuthContext +from ....security import certificates if TYPE_CHECKING: # pragma: no cover - typing aide from .. import EngineServiceAdapters @@ -33,6 +36,29 @@ def _serialize_time(now_local: datetime, now_utc: datetime) -> Dict[str, Any]: } +def _resolve_root_ca_path(adapters: "EngineServiceAdapters") -> Optional[Path]: + candidates = [] + try: + candidates.append(certificates.engine_certificates_root() / "borealis-root-ca.pem") + except Exception: + pass + + cert_path = getattr(adapters.context, "tls_cert_path", None) + if cert_path: + try: + candidates.append(Path(str(cert_path)).expanduser().resolve().parent / "borealis-root-ca.pem") + except Exception: + candidates.append(Path(str(cert_path)).parent / "borealis-root-ca.pem") + + for candidate in candidates: + try: + if candidate and candidate.is_file(): + return candidate + except Exception: + continue + return None + + def register_info(app: Flask, adapters: "EngineServiceAdapters") -> None: """Expose server telemetry endpoints used by the admin interface.""" @@ -54,4 +80,20 @@ def register_info(app: Flask, adapters: "EngineServiceAdapters") -> None: payload = _serialize_time(now_local, now_utc) return jsonify(payload) + @blueprint.route("/api/server/certificates/root", methods=["GET"]) + def server_root_ca() -> Any: + _, error = auth.require_user() + if error: + return jsonify(error[0]), error[1] + path = _resolve_root_ca_path(adapters) + if not path: + return jsonify({"error": "root_ca_missing"}), 404 + return send_file( + str(path), + mimetype="application/x-pem-file", + as_attachment=True, + download_name="borealis-root-ca.pem", + max_age=0, + ) + app.register_blueprint(blueprint) diff --git a/Data/Engine/services/RemoteDesktop/vnc_proxy.py b/Data/Engine/services/RemoteDesktop/vnc_proxy.py index 44bad107..2d8cc09f 100644 --- a/Data/Engine/services/RemoteDesktop/vnc_proxy.py +++ b/Data/Engine/services/RemoteDesktop/vnc_proxy.py @@ -152,15 +152,19 @@ class VncProxyServer: self._ready.set() await server.wait_closed() - async def _handle_client(self, websocket, path: str) -> None: - parsed = urlsplit(path) + async def _handle_client(self, websocket, path: Optional[str] = None) -> None: + raw_path = path or getattr(websocket, "path", "") or "" + parsed = urlsplit(raw_path) if parsed.path != VNC_WS_PATH: + self.logger.warning("VNC proxy rejected request with invalid path: %s", raw_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: + token_hint = token[:8] if token else "-" + self.logger.warning("VNC proxy rejected session (token=%s)", token_hint) await websocket.close(code=1008, reason="invalid_session") return diff --git a/Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx b/Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx index 974246e4..ad939053 100644 --- a/Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx +++ b/Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx @@ -13,6 +13,9 @@ import { StopRounded as StopIcon, LinkRounded as LinkIcon, LanRounded as IpIcon, + SecurityRounded as SecurityIcon, + OpenInNewRounded as OpenIcon, + DownloadRounded as DownloadIcon, } from "@mui/icons-material"; import RFB from "@novnc/novnc/lib/rfb"; @@ -46,15 +49,36 @@ function normalizeText(value) { } } +function buildCertHelp(wsUrl) { + if (!wsUrl) return null; + try { + const parsed = new URL(wsUrl); + const isSecure = parsed.protocol === "wss:"; + const trustScheme = isSecure ? "https:" : "http:"; + return { + wsUrl, + isSecure, + host: parsed.host, + trustUrl: `${trustScheme}//${parsed.host}/`, + trustCheck: "unknown", + }; + } catch { + return null; + } +} + export default function ReverseTunnelVnc({ device }) { const [sessionState, setSessionState] = useState("idle"); const [statusMessage, setStatusMessage] = useState(""); const [loading, setLoading] = useState(false); const [tunnel, setTunnel] = useState(null); + const [certHelp, setCertHelp] = useState(null); const containerRef = useRef(null); const displayRef = useRef(null); const rfbRef = useRef(null); const agentIdRef = useRef(""); + const certProbeRef = useRef(""); + const certDownloadUrl = "/api/server/certificates/root"; const agentId = useMemo(() => { return ( @@ -73,6 +97,17 @@ export default function ReverseTunnelVnc({ device }) { agentIdRef.current = agentId; }, [agentId]); + const probeCertificateTrust = useCallback(async (info) => { + if (!info || !info.isSecure || !info.trustUrl) return; + if (certProbeRef.current === info.trustUrl) return; + certProbeRef.current = info.trustUrl; + try { + await fetch(info.trustUrl, { mode: "no-cors", cache: "no-store" }); + setCertHelp((prev) => (prev ? { ...prev, trustCheck: "ok" } : prev)); + } catch { + setCertHelp((prev) => (prev ? { ...prev, trustCheck: "blocked" } : prev)); + } + }, []); const notifyAgentOnboarding = useCallback(async () => { try { @@ -138,6 +173,7 @@ export default function ReverseTunnelVnc({ device }) { await disconnectVnc("operator_disconnect"); } finally { setTunnel(null); + setCertHelp(null); setSessionState("idle"); setLoading(false); } @@ -194,6 +230,9 @@ export default function ReverseTunnelVnc({ device }) { if (!token || !wsUrl) { throw new Error("VNC session unavailable."); } + const help = buildCertHelp(wsUrl); + setCertHelp(help); + probeCertificateTrust(help); const tunnelUrl = `${wsUrl}?token=${encodeURIComponent(token)}`; const displayHost = displayRef.current; if (!displayHost) { @@ -231,12 +270,13 @@ export default function ReverseTunnelVnc({ device }) { rfbRef.current = rfb; setStatusMessage("Establishing VNC..."); - }, []); + }, [probeCertificateTrust]); const handleConnect = useCallback(async () => { if (sessionState === "connected") return; setStatusMessage(""); setSessionState("connecting"); + certProbeRef.current = ""; try { const sessionData = await requestTunnel(); if (!sessionData) { @@ -250,6 +290,8 @@ export default function ReverseTunnelVnc({ device }) { }, [openVncSession, requestTunnel, sessionState]); const isConnected = sessionState === "connected"; + const showCertHelp = + certHelp?.isSecure && (certHelp.trustCheck === "blocked" || sessionState === "error"); const sessionChips = [ tunnel?.tunnel_id ? { @@ -336,6 +378,62 @@ export default function ReverseTunnelVnc({ device }) { + {showCertHelp ? ( + + + + + + VNC proxy certificate not trusted + + + + Your browser blocked the secure VNC WebSocket. Trust the Borealis root CA (or use a + publicly trusted certificate) so the VNC proxy can connect. + + + + + + + After downloading, install the root CA into the OS trusted root store and refresh the + page. If you are behind a corporate CA, install that CA instead. + + + + ) : null} + Session: {isConnected ? "Active" : sessionState} diff --git a/Docs/api-reference.md b/Docs/api-reference.md index 386ce896..b2dc6ab9 100644 --- a/Docs/api-reference.md +++ b/Docs/api-reference.md @@ -118,6 +118,7 @@ Provide a consolidated, human-readable list of Borealis Engine API endpoints gro ### Server Info and Logs - `GET /api/server/time` (Operator Session) - server clock. +- `GET /api/server/certificates/root` (Operator Session) - download Borealis root CA certificate. - `GET /api/server/logs` (Admin) - list logs and retention. - `GET /api/server/logs//entries` (Admin) - tail log lines. - `PUT /api/server/logs/retention` (Admin) - update retention policies. diff --git a/Docs/security-and-trust.md b/Docs/security-and-trust.md index d688403c..a138ec6c 100644 --- a/Docs/security-and-trust.md +++ b/Docs/security-and-trust.md @@ -15,6 +15,7 @@ Explain the Borealis trust model, enrollment security, token handling, and code ### Overall - Borealis enforces mutual trust: each agent presents a unique Ed25519 identity to the server, the server issues EdDSA-signed (Ed25519) access tokens bound to that fingerprint, and both sides pin the generated Borealis root CA. - End-to-end TLS everywhere: the Engine auto-provisions an ECDSA P-384 root + leaf chain under `Engine/Certificates` and serves TLS using Python defaults (TLS 1.2+); agents pin the delivered bundle for both REST and WebSocket traffic to eliminate man-in-the-middle avenues. +- Operators can download the Borealis root CA via `GET /api/server/certificates/root` to trust the WebUI and VNC proxy in browsers. - Device enrollment is gated by enrollment and installer codes (configurable expiration and usage limits) and an operator approval queue; replay-resistant nonces plus rate limits (40 req/min/IP, 12 req/min/fingerprint) prevent brute force or code reuse. - All device APIs require Authorization: Bearer headers and a service-context marker (SYSTEM or CURRENTUSER); missing, expired, mismatched, or revoked credentials are rejected before any business logic runs. Operator-driven revoking and device quarantining are not yet implemented. - Replay and credential theft defenses layer in DPoP proof validation (thumbprint binding) on the server side and short-lived access tokens (about 15 minutes) with 90-day refresh tokens hashed via SHA-256.