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.
+
+
+ }
+ sx={gradientButtonSx}
+ onClick={() => {
+ if (certHelp?.trustUrl) {
+ window.open(certHelp.trustUrl, "_blank", "noreferrer");
+ }
+ }}
+ >
+ Open VNC Proxy
+
+ }
+ sx={{
+ ...gradientButtonSx,
+ backgroundImage: "linear-gradient(135deg,#34d399,#7dd3fc)",
+ }}
+ component="a"
+ href={certDownloadUrl}
+ target="_blank"
+ rel="noreferrer"
+ >
+ Download Root CA
+
+
+
+ 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.