diff --git a/.gitignore b/.gitignore
index 8ae7933d..ed0c1e2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,9 +19,8 @@ database.db
/Dependencies/Python/
/Dependencies/AutoHotKey/
/Dependencies/git/
-/Dependencies/VPN_Tunnel_Adapter/*
-!/Dependencies/VPN_Tunnel_Adapter/README.md
-!/Dependencies/VPN_Tunnel_Adapter/.gitkeep
+/Dependencies/VPN_Tunnel_Adapter/
+/Dependencies/UltraVNC_Server/
/Data/Engine/Python_API_Endpoints/
# Misc Files/Folders
diff --git a/AGENTS.md b/AGENTS.md
index 455225f5..57cd2068 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,13 +1,13 @@
# Borealis Codex Engagement Index
-Use this file as the entrypoint for Codex instructions. The full knowledgebase now lives under `Docs/` and includes both human-facing guidance and **Codex Agent** sections with deep, agent-ready details. There is no separate Codex folder anymore.
+Use this file as the entrypoint for Codex instructions. The full knowledgebase now lives under `Docs/` and includes both human-facing guidance and **Codex Agent** sections with deep, agent-ready details.
## Where to Read
- Start here: `Docs/index.md` (table of contents and documentation rules).
- Agent runtime: `Docs/agent-runtime.md` (runtime paths, logging, security, roles, platform parity, Ansible status).
- Engine runtime: `Docs/engine-runtime.md` (architecture, logging, security/API parity, platform parity, migration notes).
- UI and notifications: `Docs/ui-and-notifications.md` (MagicUI styling, AG Grid rules, toast notifications, UI handoffs).
-- VPN and remote access: `Docs/vpn-and-remote-access.md` (WireGuard tunnels, remote shell, RDP, troubleshooting context).
+- VPN and remote access: `Docs/vpn-and-remote-access.md` (WireGuard tunnels, remote shell, VNC, troubleshooting context).
- Security and trust: `Docs/security-and-trust.md` (enrollment, tokens, code signing, sequence diagrams).
Precedence: follow domain docs first; where overlap exists, the domain page wins. The Codex Agent sections inside each page are the authoritative agent guidance.
diff --git a/Borealis.ps1 b/Borealis.ps1
index 0f0ebe16..e0f4cb46 100644
--- a/Borealis.ps1
+++ b/Borealis.ps1
@@ -1501,7 +1501,151 @@ ListenPort = 0
throw "$logPrefix Driver still missing after adapter provisioning and pnputil fallback."
}
- # AutoHotKey portable
+ # UltraVNC Server (on-demand VNC backend for noVNC)
+ Run-Step "Dependency: UltraVNC Server" {
+ $uvncZipUrl = $env:BOREALIS_ULTRAVNC_ZIP_URL
+ if (-not $uvncZipUrl) {
+ $uvncZipUrl = "https://uvnc.eu/download/1640/UltraVNC_1640.zip"
+ }
+ $uvncMsiUrl = $env:BOREALIS_ULTRAVNC_MSI_URL
+ if (-not $uvncMsiUrl) {
+ $uvncMsiUrl = "https://uvnc.eu/download/1640/UltraVNC_1640_x64_Setup.msi"
+ }
+ $uvncInstallerUrl = $env:BOREALIS_ULTRAVNC_URL
+ if (-not $uvncInstallerUrl) {
+ $uvncInstallerUrl = "https://uvnc.eu/download/1640/UltraVNC_1640_x64_Setup.exe"
+ }
+ $uvncRoot = Join-Path $depsRoot "UltraVNC_Server"
+ $uvncPayloadRoot = Join-Path $uvncRoot "payload"
+ $uvncZipPath = Join-Path $uvncRoot "UltraVNC_1640.zip"
+ $uvncMsiPath = Join-Path $uvncRoot "UltraVNC_1640_x64_Setup.msi"
+ $uvncInstallerPath = Join-Path $uvncRoot "UltraVNC_1640_x64_Setup.exe"
+
+ if (-not (Test-Path $uvncRoot)) {
+ New-Item -ItemType Directory -Path $uvncRoot -Force | Out-Null
+ }
+
+ $uvncExe = Get-ChildItem -Path $uvncRoot -Recurse -Filter "winvnc*.exe" -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+
+ if (-not $uvncExe) {
+ if (-not (Test-Path $sevenZipExe)) {
+ throw "7-Zip CLI not found at: $sevenZipExe"
+ }
+ try {
+ if (-not (Test-Path $uvncZipPath)) {
+ Invoke-WebRequest -Uri $uvncZipUrl -OutFile $uvncZipPath
+ }
+ if (Test-Path $uvncPayloadRoot) {
+ Remove-Item $uvncPayloadRoot -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ New-Item -ItemType Directory -Path $uvncPayloadRoot -Force | Out-Null
+ & $sevenZipExe x $uvncZipPath "-o$uvncPayloadRoot" -y | Out-Null
+ } catch {
+ Write-Host "UltraVNC zip download/extract failed. Trying MSI fallback." -ForegroundColor Yellow
+ }
+ $uvncExe = Get-ChildItem -Path $uvncPayloadRoot -Recurse -Filter "winvnc*.exe" -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ }
+
+ if (-not $uvncExe) {
+ try {
+ if (-not (Test-Path $uvncMsiPath)) {
+ Invoke-WebRequest -Uri $uvncMsiUrl -OutFile $uvncMsiPath
+ }
+ $msiExtractRoot = Join-Path $uvncPayloadRoot "msi_extract"
+ if (Test-Path $msiExtractRoot) {
+ Remove-Item $msiExtractRoot -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ New-Item -ItemType Directory -Path $msiExtractRoot -Force | Out-Null
+ $msiArgs = @(
+ "/a",
+ "`"$uvncMsiPath`"",
+ "/qn",
+ "TARGETDIR=`"$msiExtractRoot`""
+ )
+ $msiProc = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -Wait -NoNewWindow -PassThru
+ if ($msiProc.ExitCode -ne 0) {
+ Write-Host "UltraVNC MSI extraction failed with code $($msiProc.ExitCode). Trying installer fallback." -ForegroundColor Yellow
+ }
+ } catch {
+ Write-Host "UltraVNC MSI extraction failed. Trying installer fallback." -ForegroundColor Yellow
+ }
+ $uvncExe = Get-ChildItem -Path $uvncPayloadRoot -Recurse -Filter "winvnc*.exe" -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ }
+
+ if (-not $uvncExe) {
+ if (-not (Test-Path $uvncInstallerPath)) {
+ Invoke-WebRequest -Uri $uvncInstallerUrl -OutFile $uvncInstallerPath
+ }
+ if (-not (Test-Path $sevenZipExe)) {
+ throw "7-Zip CLI not found at: $sevenZipExe"
+ }
+ try {
+ if (-not (Test-Path $uvncPayloadRoot)) {
+ New-Item -ItemType Directory -Path $uvncPayloadRoot -Force | Out-Null
+ }
+ & $sevenZipExe x $uvncInstallerPath "-o$uvncPayloadRoot" -y | Out-Null
+ } catch {
+ Write-Host "UltraVNC installer extraction failed." -ForegroundColor Yellow
+ }
+ $uvncExe = Get-ChildItem -Path $uvncPayloadRoot -Recurse -Filter "winvnc*.exe" -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ }
+
+ $passwordTool = Get-ChildItem -Path $uvncRoot -Recurse -Filter "createpassword.exe" -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if (-not $passwordTool) {
+ $passwordToolUrl = $env:BOREALIS_VNC_PASSWORD_TOOL_URL
+ if (-not $passwordToolUrl) {
+ $passwordToolUrl = "https://uvnc.eu/download/133/createpassword.zip"
+ }
+ $passwordToolZip = Join-Path $uvncRoot "createpassword.zip"
+ try {
+ if (-not (Test-Path $passwordToolZip)) {
+ Invoke-WebRequest -Uri $passwordToolUrl -OutFile $passwordToolZip
+ }
+ if (Test-Path $sevenZipExe) {
+ $toolDir = Join-Path $uvncRoot "tools"
+ if (Test-Path $toolDir) {
+ Remove-Item $toolDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ New-Item -ItemType Directory -Path $toolDir | Out-Null
+ & $sevenZipExe x $passwordToolZip "-o$toolDir" -y | Out-Null
+ }
+ } catch {
+ Write-Host "UltraVNC createpassword tool download failed. Set BOREALIS_VNC_PASSWORD_TOOL_URL to override." -ForegroundColor Yellow
+ }
+ }
+
+ if (-not $uvncExe) {
+ Write-Host "UltraVNC server binary not found. Ensure winvnc.exe exists under Dependencies\\UltraVNC_Server." -ForegroundColor Yellow
+ } else {
+ $uvncServiceName = $env:BOREALIS_ULTRAVNC_SERVICE
+ if (-not $uvncServiceName) {
+ $uvncServiceName = "uvnc_service"
+ }
+ try {
+ & $uvncExe.FullName -install | Out-Null
+ $uvncService = Get-Service -Name $uvncServiceName -ErrorAction SilentlyContinue
+ if (-not $uvncService) {
+ $uvncService = Get-Service -ErrorAction SilentlyContinue | Where-Object {
+ $_.Name -like "*uvnc*" -or $_.DisplayName -like "*UltraVNC*"
+ } | Select-Object -First 1
+ }
+ if ($uvncService) {
+ sc.exe config $uvncService.Name start= auto | Out-Null
+ sc.exe start $uvncService.Name | Out-Null
+ } else {
+ Write-Host "UltraVNC service not found after install. Set BOREALIS_ULTRAVNC_SERVICE to override." -ForegroundColor Yellow
+ }
+ } catch {
+ Write-Host "UltraVNC service setup failed: $($_.Exception.Message)" -ForegroundColor Yellow
+ }
+ }
+ }
+
Run-Step "Dependency: AutoHotKey" {
$ahkVersion = "2.0.19"
$ahkVersionTag = "v$ahkVersion"
diff --git a/Data/Agent/Roles/role_RDP.py b/Data/Agent/Roles/role_RDP.py
deleted file mode 100644
index 60ccf3ac..00000000
--- a/Data/Agent/Roles/role_RDP.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# ======================================================
-# Data\Agent\Roles\role_RDP.py
-# Description: Optional RDP readiness helper for Borealis (Windows-only).
-#
-# API Endpoints (if applicable): None
-# ======================================================
-
-"""RDP readiness helper role (no-op unless enabled via environment flags)."""
-
-from __future__ import annotations
-
-import os
-import subprocess
-import time
-from pathlib import Path
-
-ROLE_NAME = "RDP"
-ROLE_CONTEXTS = ["system"]
-
-
-def _log_path() -> Path:
- root = Path(__file__).resolve().parents[2] / "Logs"
- root.mkdir(parents=True, exist_ok=True)
- return root / "rdp.log"
-
-
-def _write_log(message: str) -> None:
- ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
- try:
- _log_path().open("a", encoding="utf-8").write(f"[{ts}] [rdp-role] {message}\n")
- except Exception:
- pass
-
-
-def _bool_env(name: str) -> bool:
- value = os.environ.get(name)
- if value is None:
- return False
- return str(value).strip().lower() in {"1", "true", "yes", "on"}
-
-
-def _enable_rdp_windows() -> None:
- command = (
- "Set-ItemProperty -Path 'HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server' "
- "-Name fDenyTSConnections -Value 0; "
- "Set-Service -Name TermService -StartupType Automatic; "
- "Start-Service -Name TermService; "
- "Enable-NetFirewallRule -DisplayGroup 'Remote Desktop'"
- )
- try:
- result = subprocess.run(
- ["powershell.exe", "-NoProfile", "-Command", command],
- capture_output=True,
- text=True,
- check=False,
- )
- if result.returncode != 0:
- _write_log(f"RDP enable failed: {result.stderr.strip()}")
- else:
- _write_log("RDP enable applied (registry/service/firewall).")
- except Exception as exc:
- _write_log(f"RDP enable failed: {exc}")
-
-
-class Role:
- def __init__(self, ctx) -> None:
- self.ctx = ctx
- auto_enable = _bool_env("BOREALIS_RDP_AUTO_ENABLE")
- _write_log(f"RDP role loaded auto_enable={auto_enable}")
- if auto_enable and os.name == "nt":
- _enable_rdp_windows()
-
- def register_events(self) -> None:
- return
-
- def stop_all(self) -> None:
- return
diff --git a/Data/Agent/Roles/role_VNC.py b/Data/Agent/Roles/role_VNC.py
new file mode 100644
index 00000000..42cfab5e
--- /dev/null
+++ b/Data/Agent/Roles/role_VNC.py
@@ -0,0 +1,468 @@
+# ======================================================
+# Data/Agent/Roles/role_VNC.py
+# Description: On-demand UltraVNC server lifecycle over WireGuard.
+#
+# API Endpoints (if applicable): None
+# ======================================================
+
+"""UltraVNC role (Windows) for on-demand VNC sessions over WireGuard."""
+from __future__ import annotations
+
+import ipaddress
+import os
+import subprocess
+import threading
+import time
+from pathlib import Path
+from typing import Any, Optional
+
+ROLE_NAME = "VNC"
+ROLE_CONTEXTS = ["system"]
+
+VNC_FIREWALL_RULE_NAME = "Borealis - VNC - UltraVNC"
+DEFAULT_VNC_PORT = 5900
+ULTRAVNC_SERVICE_NAME = os.environ.get("BOREALIS_ULTRAVNC_SERVICE") or "uvnc_service"
+
+
+def _log_path() -> Path:
+ root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel"
+ root.mkdir(parents=True, exist_ok=True)
+ return root / "vnc.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}] [vnc] {message}\n")
+ except Exception:
+ pass
+
+
+def _find_project_root() -> Optional[Path]:
+ override = os.environ.get("BOREALIS_ROOT") or os.environ.get("BOREALIS_PROJECT_ROOT")
+ if override:
+ try:
+ override_path = Path(override).expanduser().resolve()
+ if override_path.is_dir():
+ return override_path
+ except Exception:
+ pass
+ current = Path(__file__).resolve()
+ for parent in (current, *current.parents):
+ try:
+ if (parent / "Borealis.ps1").is_file() or (parent / "users.json").is_file():
+ return parent
+ except Exception:
+ continue
+ try:
+ return current.parents[3]
+ except Exception:
+ return None
+
+
+def _resolve_vnc_root() -> Optional[Path]:
+ root = _find_project_root()
+ if not root:
+ return None
+ candidate = root / "Dependencies" / "UltraVNC_Server"
+ if candidate.is_dir():
+ return candidate
+ return None
+
+
+def _resolve_vnc_exe() -> Optional[str]:
+ override = os.environ.get("BOREALIS_VNC_SERVER_BIN")
+ if override:
+ try:
+ if Path(override).is_file():
+ return str(Path(override))
+ except Exception:
+ pass
+ vnc_root = _resolve_vnc_root()
+ if vnc_root:
+ preferred = [
+ vnc_root / "payload" / "x64" / "winvnc.exe",
+ vnc_root / "payload" / "x64" / "winvnc64.exe",
+ vnc_root / "winvnc64.exe",
+ vnc_root / "winvnc.exe",
+ vnc_root / "payload" / "x86" / "winvnc.exe",
+ ]
+ for candidate in preferred:
+ if candidate.is_file():
+ return str(candidate)
+ try:
+ for candidate in vnc_root.rglob("winvnc*.exe"):
+ if candidate.is_file():
+ return str(candidate)
+ except Exception:
+ pass
+ return None
+
+
+def _resolve_vnc_port(value: Any = None) -> int:
+ raw = value if value is not None else os.environ.get("BOREALIS_VNC_PORT")
+ try:
+ port = int(raw) if raw is not None else DEFAULT_VNC_PORT
+ except Exception:
+ port = DEFAULT_VNC_PORT
+ if port < 1 or port > 65535:
+ return DEFAULT_VNC_PORT
+ return port
+
+
+def _resolve_vnc_password_tool(root: Optional[Path]) -> Optional[str]:
+ override = os.environ.get("BOREALIS_VNC_PASSWORD_TOOL")
+ if override:
+ try:
+ if Path(override).is_file():
+ return str(Path(override))
+ except Exception:
+ pass
+ candidates: list[Path] = []
+ if root:
+ candidates.extend(
+ [
+ root / "createpassword.exe",
+ root / "tools" / "createpassword.exe",
+ root / "createpassword64.exe",
+ root / "tools" / "createpassword64.exe",
+ root / "CreatePassword.exe",
+ ]
+ )
+ vnc_root = _resolve_vnc_root()
+ if vnc_root and vnc_root != root:
+ candidates.extend(
+ [
+ vnc_root / "createpassword.exe",
+ vnc_root / "tools" / "createpassword.exe",
+ vnc_root / "createpassword64.exe",
+ vnc_root / "tools" / "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)
+ except Exception:
+ pass
+ return None
+
+
+def _ensure_ultravnc_ini(config_dir: Path, port: int) -> Optional[Path]:
+ try:
+ config_dir.mkdir(parents=True, exist_ok=True)
+ except Exception:
+ pass
+ ini_path = config_dir / "ultravnc.ini"
+ try:
+ lines = [
+ "UseRegistry=0",
+ "AuthRequired=1",
+ "MSLogonRequired=0",
+ "NewMSLogon=0",
+ f"PortNumber={port}",
+ "AutoPortSelect=0",
+ "SocketConnect=1",
+ "HTTPConnect=0",
+ "AllowShutdown=0",
+ "DisableTrayIcon=1",
+ "EnableFileTransfer=0",
+ ]
+ ini_path.write_text("\n".join(lines), encoding="utf-8")
+ return ini_path
+ except Exception as exc:
+ _write_log(f"Failed to write ultravnc.ini: {exc}")
+ return None
+
+
+def _parse_allowed_ips(value: Any) -> Optional[str]:
+ if isinstance(value, list):
+ if not value:
+ return None
+ return str(value[0])
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
+
+
+class VncManager:
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self._last_port: Optional[int] = None
+ self._last_password: Optional[str] = None
+ self._vnc_exe = _resolve_vnc_exe()
+ self._password_tool: Optional[str] = None
+
+ def _service_state(self) -> Optional[str]:
+ if os.name != "nt":
+ return None
+ try:
+ result = subprocess.run(
+ ["sc.exe", "query", ULTRAVNC_SERVICE_NAME],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ return None
+ for line in (result.stdout or "").splitlines():
+ if "STATE" in line:
+ parts = line.strip().split()
+ if parts:
+ return parts[-1].upper()
+ except Exception:
+ return None
+ return None
+
+ def _restart_service(self) -> None:
+ if os.name != "nt":
+ return
+ state = self._service_state()
+ if state != "RUNNING":
+ return
+ try:
+ subprocess.run(["sc.exe", "stop", ULTRAVNC_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)
+ 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()
+ if state == "RUNNING":
+ return True
+ if not self._vnc_exe:
+ self._vnc_exe = _resolve_vnc_exe()
+ if not self._vnc_exe:
+ return False
+ try:
+ if state is None:
+ subprocess.run([self._vnc_exe, "-install"], capture_output=True, text=True, check=False)
+ subprocess.run(
+ ["sc.exe", "config", ULTRAVNC_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)
+ except Exception as exc:
+ _write_log(f"Failed to ensure UltraVNC service running: {exc}")
+ return False
+ return self._service_state() == "RUNNING"
+
+ def _normalize_firewall_remote(self, allowed_ips: Optional[str]) -> Optional[str]:
+ if not allowed_ips:
+ return None
+ try:
+ network = ipaddress.ip_network(str(allowed_ips).strip(), strict=False)
+ except Exception:
+ _write_log(f"Refusing to apply VNC firewall rule; invalid allowed_ips={allowed_ips}.")
+ return None
+ if network.prefixlen != 32:
+ _write_log(f"Refusing to apply VNC firewall rule; allowed_ips not /32: {network}.")
+ return None
+ return str(network)
+
+ def _ensure_firewall(self, allowed_ips: Optional[str], port: int) -> None:
+ if os.name != "nt":
+ return
+ remote = self._normalize_firewall_remote(allowed_ips)
+ if not remote:
+ return
+ rule_name = VNC_FIREWALL_RULE_NAME.replace("'", "''")
+ command = (
+ "Remove-NetFirewallRule -DisplayName '{name}' -ErrorAction SilentlyContinue; "
+ "New-NetFirewallRule -DisplayName '{name}' -Direction Inbound -Action Allow "
+ "-Protocol TCP -LocalPort {port} -RemoteAddress {remote} -Profile Any"
+ ).format(name=rule_name, port=port, remote=remote)
+ try:
+ result = subprocess.run(
+ ["powershell.exe", "-NoProfile", "-Command", command],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ _write_log(f"Failed to ensure VNC firewall rule: {result.stderr.strip()}")
+ else:
+ _write_log(f"Ensured VNC firewall rule for {remote} on port {port}.")
+ except Exception as exc:
+ _write_log(f"Failed to ensure VNC firewall rule: {exc}")
+
+ def _remove_firewall(self) -> None:
+ if os.name != "nt":
+ return
+ rule_name = VNC_FIREWALL_RULE_NAME.replace("'", "''")
+ command = "Remove-NetFirewallRule -DisplayName '{name}' -ErrorAction SilentlyContinue".format(
+ name=rule_name
+ )
+ try:
+ subprocess.run(
+ ["powershell.exe", "-NoProfile", "-Command", command],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ except Exception:
+ pass
+
+ def _apply_password(self, config_dir: Path, password: str) -> Optional[str]:
+ if not password:
+ _write_log("VNC password missing; refusing to start without auth.")
+ return None
+ trimmed = str(password)[:8]
+ if trimmed != password:
+ _write_log("VNC password trimmed to 8 characters for UltraVNC compatibility.")
+ 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.")
+ return None
+ try:
+ result = subprocess.run(
+ [self._password_tool, "-secure", trimmed],
+ cwd=str(config_dir),
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ _write_log(f"Failed to apply VNC password: {result.stderr.strip()}")
+ return None
+ return trimmed
+ except Exception as exc:
+ _write_log(f"Failed to apply VNC password: {exc}")
+ return None
+
+ def start(
+ self,
+ *,
+ port: Optional[int],
+ allowed_ips: Optional[str],
+ password: Optional[str],
+ reason: str = "start",
+ ) -> None:
+ with self._lock:
+ port_value = _resolve_vnc_port(port)
+ self._ensure_firewall(allowed_ips, port_value)
+
+ 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.")
+ return
+
+ exe_path = Path(self._vnc_exe)
+ config_dir = exe_path.parent
+ ini_path = _ensure_ultravnc_ini(config_dir, port_value)
+ if not ini_path:
+ return
+ applied_password = self._apply_password(config_dir, password or "")
+ if not applied_password:
+ return
+
+ if not self._ensure_service_running():
+ _write_log("Failed to start UltraVNC service.")
+ return
+
+ if self._last_port != port_value or self._last_password != applied_password:
+ self._restart_service()
+ self._last_port = port_value
+ self._last_password = applied_password
+ _write_log(f"VNC service running port={port_value} reason={reason}.")
+
+ def stop(self, *, reason: str = "stop") -> None:
+ with self._lock:
+ self._remove_firewall()
+ self._last_port = None
+ _write_log(f"VNC firewall closed reason={reason}.")
+
+
+class Role:
+ def __init__(self, ctx) -> None:
+ self.ctx = ctx
+ self.vnc = VncManager()
+ self._last_allowed_ips: Optional[str] = None
+ hooks = getattr(ctx, "hooks", {}) or {}
+ self._log_hook = hooks.get("log_agent")
+ try:
+ self.vnc.stop(reason="agent_startup")
+ except Exception:
+ self._log("Failed to preflight VNC cleanup.", error=True)
+ try:
+ self.vnc._ensure_service_running()
+ except Exception:
+ self._log("Failed to ensure UltraVNC service running.", error=True)
+
+ def _log(self, message: str, *, error: bool = False) -> None:
+ if callable(self._log_hook):
+ try:
+ self._log_hook(message, fname="VPN_Tunnel/vnc.log")
+ if error:
+ self._log_hook(message, fname="agent.error.log")
+ except Exception:
+ pass
+ _write_log(message)
+
+ def register_events(self) -> None:
+ sio = self.ctx.sio
+
+ @sio.on("vpn_tunnel_start")
+ async def _vpn_tunnel_start(payload):
+ if isinstance(payload, dict):
+ target_agent = payload.get("agent_id")
+ if target_agent and str(target_agent).strip() != str(self.ctx.agent_id).strip():
+ return
+ allowed_ips = payload.get("allowed_ips")
+ self._last_allowed_ips = _parse_allowed_ips(allowed_ips)
+
+ @sio.on("vpn_tunnel_stop")
+ async def _vpn_tunnel_stop(payload):
+ reason = "server_stop"
+ if isinstance(payload, dict):
+ target_agent = payload.get("agent_id")
+ if target_agent and str(target_agent).strip() != str(self.ctx.agent_id).strip():
+ return
+ reason = payload.get("reason") or reason
+ self._log(f"VNC stop requested (reason={reason}).")
+ self.vnc.stop(reason=str(reason))
+
+ @sio.on("vnc_start")
+ async def _vnc_start(payload):
+ if isinstance(payload, dict):
+ target_agent = payload.get("agent_id")
+ if target_agent and str(target_agent).strip() != str(self.ctx.agent_id).strip():
+ return
+ port = payload.get("port")
+ allowed_ips = payload.get("allowed_ips") or self._last_allowed_ips
+ password = payload.get("password") or ""
+ reason = payload.get("reason") or "vnc_session_start"
+ else:
+ port = None
+ allowed_ips = self._last_allowed_ips
+ password = ""
+ reason = "vnc_session_start"
+ self._log(f"VNC start request received (reason={reason}).")
+ self.vnc.start(port=port, allowed_ips=allowed_ips, password=password, reason=str(reason))
+
+ @sio.on("vnc_stop")
+ async def _vnc_stop(payload):
+ reason = "vnc_session_end"
+ if isinstance(payload, dict):
+ target_agent = payload.get("agent_id")
+ if target_agent and str(target_agent).strip() != str(self.ctx.agent_id).strip():
+ return
+ reason = payload.get("reason") or reason
+ self._log(f"VNC stop requested (reason={reason}).")
+ self.vnc.stop(reason=str(reason))
+
+ def stop_all(self) -> None:
+ try:
+ self.vnc.stop(reason="agent_shutdown")
+ except Exception:
+ self._log("Failed to stop VNC during shutdown.", error=True)
diff --git a/Data/Agent/Roles/role_WireGuardTunnel.py b/Data/Agent/Roles/role_WireGuardTunnel.py
index 26177a65..3b99385e 100644
--- a/Data/Agent/Roles/role_WireGuardTunnel.py
+++ b/Data/Agent/Roles/role_WireGuardTunnel.py
@@ -8,10 +8,10 @@
"""WireGuard client role (Windows) for reverse VPN tunnels.
This role prepares the WireGuard client config, manages a single active
-session, enforces idle teardown, and logs lifecycle events to
-Agent/Logs/VPN_Tunnel/tunnel.log. It binds to Engine Socket.IO events
-(`vpn_tunnel_start`, `vpn_tunnel_stop`, `vpn_tunnel_activity`) to start/stop
-the client session with the issued config/token.
+session, and keeps the tunnel online while the agent service runs. It logs
+lifecycle events to Agent/Logs/VPN_Tunnel/tunnel.log. It responds to Engine
+Socket.IO events (`vpn_tunnel_start`, `vpn_tunnel_activity`) and periodically
+ensures the persistent session via `/api/agent/vpn/ensure`.
"""
from __future__ import annotations
@@ -55,6 +55,24 @@ TUNNEL_IDLE_ADDRESS = "169.254.255.254/32"
FIREWALL_RULE_NAME = "Borealis - WireGuard - Shell"
+def _env_int(name: str, default: int, *, min_value: int = 1, max_value: int = 3600) -> int:
+ raw = os.environ.get(name)
+ try:
+ value = int(raw) if raw is not None else default
+ except Exception:
+ value = default
+ if value < min_value:
+ return min_value
+ if value > max_value:
+ return max_value
+ return value
+
+
+KEEPALIVE_SECONDS = _env_int("BOREALIS_WIREGUARD_KEEPALIVE_SECONDS", 30, min_value=10, max_value=600)
+ENSURE_INITIAL_DELAY_SECONDS = _env_int("BOREALIS_WIREGUARD_ENSURE_DELAY", 10, min_value=0, max_value=300)
+ENSURE_INTERVAL_SECONDS = _env_int("BOREALIS_WIREGUARD_ENSURE_INTERVAL", 60, min_value=15, max_value=3600)
+
+
def _log_path() -> Path:
root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel"
root.mkdir(parents=True, exist_ok=True)
@@ -69,6 +87,8 @@ def _write_log(message: str) -> None:
pass
+
+
def _encode_key(raw: bytes) -> str:
return base64.b64encode(raw).decode("ascii").strip()
@@ -125,6 +145,7 @@ class SessionConfig:
self,
*,
token: Dict[str, Any],
+ tunnel_id: str,
virtual_ip: str,
allowed_ips: str,
endpoint: str,
@@ -136,6 +157,7 @@ class SessionConfig:
client_public_key: Optional[str] = None,
) -> None:
self.token = token
+ self.tunnel_id = tunnel_id
self.virtual_ip = virtual_ip
self.allowed_ips = allowed_ips
self.endpoint = endpoint
@@ -199,7 +221,7 @@ class WireGuardClient:
return True
except Exception as exc:
_write_log(f"WireGuard service registry check failed: {exc}")
- return False
+ return False
def _service_image_path(self) -> Optional[str]:
if winreg is None:
@@ -223,6 +245,19 @@ class WireGuardClient:
return Path(match.group(1))
return None
+ def _service_state(self) -> Optional[str]:
+ code, out, err = self._run(["sc.exe", "query", self._service_id()])
+ if code != 0:
+ return None
+ text = out or err or ""
+ for line in text.splitlines():
+ if "STATE" not in line:
+ continue
+ match = re.search(r"STATE\s*:\s*\d+\s+(\w+)", line)
+ if match:
+ return match.group(1).upper()
+ return None
+
def _wireguard_config_path(self) -> Path:
settings_dir = self.temp_root.parent / "Settings" / "WireGuard"
candidates = [
@@ -457,7 +492,7 @@ class WireGuardClient:
f"PublicKey = {session.server_public_key}",
f"AllowedIPs = {session.allowed_ips}",
f"Endpoint = {session.endpoint}",
- "PersistentKeepalive = 20",
+ f"PersistentKeepalive = {KEEPALIVE_SECONDS}",
]
if session.preshared_key:
lines.append(f"PresharedKey = {session.preshared_key}")
@@ -478,11 +513,45 @@ class WireGuardClient:
t.start()
self._idle_thread = t
+ def _stop_session_locked(self, reason: str = "stop", ignore_missing: bool = False) -> None:
+ self._remove_shell_firewall()
+ if not self._service_exists():
+ if not ignore_missing:
+ _write_log("WireGuard tunnel service not found when stopping session.")
+ self.session = None
+ self.idle_deadline = None
+ self._stop_event.set()
+ return
+
+ idle_config = self._render_idle_config()
+ wrote_idle = self._write_config(idle_config)
+ service_config_path = self._service_config_path()
+ if service_config_path and service_config_path != self.conf_path:
+ wrote_idle = self._write_config_to(service_config_path, idle_config) or wrote_idle
+ if wrote_idle:
+ self._restart_service()
+ self._ensure_adapter_name()
+ self._ensure_service_display_name()
+ _write_log(f"WireGuard client session stopped (reason={reason}).")
+ elif not ignore_missing:
+ _write_log("Failed to write idle WireGuard config.")
+ self.session = None
+ self.idle_deadline = None
+ self._stop_event.set()
+
def start_session(self, session: SessionConfig, *, signing_client: Optional[Any] = None) -> None:
with self._session_lock:
if self.session:
- _write_log("Rejecting start_session: existing session already active.")
- return
+ if self.session.tunnel_id == session.tunnel_id:
+ _write_log("WireGuard session already active; reusing existing session.")
+ self.bump_activity()
+ return
+ _write_log(
+ "WireGuard session replace: existing_tunnel_id={0} new_tunnel_id={1}".format(
+ self.session.tunnel_id, session.tunnel_id
+ )
+ )
+ self._stop_session_locked(reason="session_replace", ignore_missing=True)
try:
self._validate_token(session.token, signing_client=signing_client)
@@ -519,41 +588,15 @@ class WireGuardClient:
self._ensure_shell_firewall(session.allowed_ips)
self.session = session
- self.idle_deadline = time.time() + max(60, session.idle_seconds)
- _write_log("WireGuard client session started; idle timer armed.")
- self._start_idle_monitor()
+ self.idle_deadline = None
+ _write_log("WireGuard client session started (persistent mode).")
def stop_session(self, reason: str = "stop", ignore_missing: bool = False) -> None:
with self._session_lock:
- self._remove_shell_firewall()
- if not self._service_exists():
- if not ignore_missing:
- _write_log("WireGuard tunnel service not found when stopping session.")
- self.session = None
- self.idle_deadline = None
- self._stop_event.set()
- return
-
- idle_config = self._render_idle_config()
- wrote_idle = self._write_config(idle_config)
- service_config_path = self._service_config_path()
- if service_config_path and service_config_path != self.conf_path:
- wrote_idle = self._write_config_to(service_config_path, idle_config) or wrote_idle
- if wrote_idle:
- self._restart_service()
- self._ensure_adapter_name()
- self._ensure_service_display_name()
- _write_log(f"WireGuard client session stopped (reason={reason}).")
- elif not ignore_missing:
- _write_log("Failed to write idle WireGuard config.")
- self.session = None
- self.idle_deadline = None
- self._stop_event.set()
+ self._stop_session_locked(reason=reason, ignore_missing=ignore_missing)
def bump_activity(self) -> None:
- if self.session and self.idle_deadline:
- self.idle_deadline = time.time() + max(60, self.session.idle_seconds)
- _write_log("WireGuard client activity bump; idle timer reset.")
+ return
_client: Optional[WireGuardClient] = None
@@ -686,10 +729,9 @@ class Role:
self._log_hook = hooks.get("log_agent")
self._http_client_factory = hooks.get("http_client")
self._get_server_url = hooks.get("get_server_url")
- try:
- self.client.stop_session(reason="agent_startup", ignore_missing=True)
- except Exception:
- self._log("Failed to preflight WireGuard session cleanup.", error=True)
+ self._ensure_stop = threading.Event()
+ self._ensure_thread = threading.Thread(target=self._ensure_loop, daemon=True)
+ self._ensure_thread.start()
def _log(self, message: str, *, error: bool = False) -> None:
if callable(self._log_hook):
@@ -751,6 +793,11 @@ class Role:
self._log("WireGuard start missing token payload.", error=True)
return None
+ tunnel_id = payload.get("tunnel_id") or token.get("tunnel_id")
+ if not tunnel_id:
+ self._log("WireGuard start missing tunnel_id.", error=True)
+ return None
+
virtual_ip = payload.get("virtual_ip") or payload.get("client_virtual_ip")
endpoint = payload.get("endpoint") or payload.get("server_endpoint")
endpoint = self._resolve_endpoint(endpoint, token)
@@ -780,6 +827,7 @@ class Role:
return SessionConfig(
token=token,
+ tunnel_id=str(tunnel_id),
virtual_ip=str(virtual_ip),
allowed_ips=str(allowed_ips),
endpoint=str(endpoint),
@@ -791,6 +839,51 @@ class Role:
client_public_key=payload.get("client_public_key"),
)
+ def _request_persistent_session(self) -> Optional[Dict[str, Any]]:
+ client = self._http_client()
+ if client is None:
+ return None
+ try:
+ payload = client.post_json(
+ "/api/agent/vpn/ensure",
+ {"agent_id": self.ctx.agent_id, "reason": "agent_boot"},
+ require_auth=True,
+ )
+ except Exception as exc:
+ self._log(f"WireGuard ensure request failed: {exc}", error=True)
+ return None
+ if not isinstance(payload, dict):
+ return None
+ return payload
+
+ def _ensure_loop(self) -> None:
+ if ENSURE_INITIAL_DELAY_SECONDS:
+ time.sleep(ENSURE_INITIAL_DELAY_SECONDS)
+ while not self._ensure_stop.is_set():
+ payload = self._request_persistent_session()
+ if payload:
+ incoming_tunnel = str(payload.get("tunnel_id") or "")
+ current_tunnel = ""
+ if self.client.session is not None:
+ try:
+ current_tunnel = str(self.client.session.tunnel_id)
+ except Exception:
+ current_tunnel = ""
+ state = None
+ try:
+ state = self.client._service_state()
+ except Exception:
+ state = None
+ service_ready = state in ("RUNNING", "START_PENDING")
+ if incoming_tunnel and incoming_tunnel == current_tunnel and service_ready:
+ self._ensure_stop.wait(ENSURE_INTERVAL_SECONDS)
+ continue
+ session = self._build_session(payload)
+ if session:
+ self._log("WireGuard persistent session ensure received.")
+ self.client.start_session(session, signing_client=self._http_client())
+ self._ensure_stop.wait(ENSURE_INTERVAL_SECONDS)
+
def register_events(self) -> None:
sio = self.ctx.sio
@@ -810,14 +903,18 @@ class Role:
if target_agent and str(target_agent).strip() != str(self.ctx.agent_id).strip():
return
reason = payload.get("reason") or reason
- self._log(f"WireGuard stop requested (reason={reason}).")
- self.client.stop_session(reason=str(reason))
+ self._log(f"WireGuard stop requested (reason={reason}); persistent tunnels ignore stop.")
+
@sio.on("vpn_tunnel_activity")
async def _vpn_tunnel_activity(payload):
self.client.bump_activity()
def stop_all(self) -> None:
+ try:
+ self._ensure_stop.set()
+ except Exception:
+ pass
try:
self.client.stop_session(reason="agent_shutdown")
except Exception:
diff --git a/Data/Engine/config.py b/Data/Engine/config.py
index dc883777..24253043 100644
--- a/Data/Engine/config.py
+++ b/Data/Engine/config.py
@@ -81,14 +81,19 @@ VPN_TUNNEL_LOG_FILE_PATH = LOG_ROOT / "VPN_Tunnel" / "tunnel.log"
DEFAULT_WIREGUARD_PORT = 30000
DEFAULT_WIREGUARD_ENGINE_VIRTUAL_IP = "10.255.0.1/32"
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
+DEFAULT_VNC_PORT = 5900
+DEFAULT_WIREGUARD_SHELL_PORT = 47002
+DEFAULT_WIREGUARD_ACL_WINDOWS = (
+ 5985,
+ 5986,
+ 5900,
+ 3478,
+ DEFAULT_WIREGUARD_SHELL_PORT,
+)
+DEFAULT_VNC_WS_HOST = "0.0.0.0"
+DEFAULT_VNC_WS_PORT = 4823
+DEFAULT_VNC_SESSION_TTL_SECONDS = 120
def _ensure_parent(path: Path) -> None:
@@ -290,11 +295,10 @@ 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
+ vnc_port: int
+ vnc_ws_host: str
+ vnc_ws_port: int
+ vnc_session_ttl_seconds: int
raw: MutableMapping[str, Any] = field(default_factory=dict)
def to_flask_config(self) -> MutableMapping[str, Any]:
@@ -437,32 +441,27 @@ 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,
+ vnc_port = _parse_int(
+ runtime_config.get("VNC_PORT") or os.environ.get("BOREALIS_VNC_PORT"),
+ default=DEFAULT_VNC_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
+ vnc_ws_host = str(
+ runtime_config.get("VNC_WS_HOST")
+ or os.environ.get("BOREALIS_VNC_WS_HOST")
+ or DEFAULT_VNC_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,
+ vnc_ws_port = _parse_int(
+ runtime_config.get("VNC_WS_PORT") or os.environ.get("BOREALIS_VNC_WS_PORT"),
+ default=DEFAULT_VNC_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,
+ vnc_session_ttl_seconds = _parse_int(
+ runtime_config.get("VNC_SESSION_TTL_SECONDS")
+ or os.environ.get("BOREALIS_VNC_SESSION_TTL_SECONDS"),
+ default=DEFAULT_VNC_SESSION_TTL_SECONDS,
minimum=30,
maximum=3600,
)
@@ -505,11 +504,10 @@ 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,
+ vnc_port=vnc_port,
+ vnc_ws_host=vnc_ws_host,
+ vnc_ws_port=vnc_ws_port,
+ vnc_session_ttl_seconds=vnc_session_ttl_seconds,
raw=runtime_config,
)
return settings
diff --git a/Data/Engine/database_migrations.py b/Data/Engine/database_migrations.py
index 8e628e52..5109ac59 100644
--- a/Data/Engine/database_migrations.py
+++ b/Data/Engine/database_migrations.py
@@ -70,6 +70,7 @@ def _ensure_devices_table(conn: sqlite3.Connection) -> None:
"ansible_ee_ver": "TEXT",
"connection_type": "TEXT",
"connection_endpoint": "TEXT",
+ "agent_vnc_password": "TEXT",
"ssl_key_fingerprint": "TEXT",
"token_version": "INTEGER",
"status": "TEXT",
@@ -388,6 +389,7 @@ def _create_devices_table(cur: sqlite3.Cursor) -> None:
ansible_ee_ver TEXT,
connection_type TEXT,
connection_endpoint TEXT,
+ agent_vnc_password TEXT,
ssl_key_fingerprint TEXT,
token_version INTEGER DEFAULT 1,
status TEXT DEFAULT 'active',
@@ -455,9 +457,9 @@ def _rebuild_devices_table(conn: sqlite3.Connection, column_info: Sequence[Tuple
network, software, storage, cpu, device_type, domain, external_ip,
internal_ip, last_reboot, last_seen, last_user, operating_system,
uptime, agent_id, ansible_ee_ver, connection_type, connection_endpoint,
- ssl_key_fingerprint, token_version, status, key_added_at
+ agent_vnc_password, ssl_key_fingerprint, token_version, status, key_added_at
)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
)
@@ -496,6 +498,7 @@ def _rebuild_devices_table(conn: sqlite3.Connection, column_info: Sequence[Tuple
record.get("ansible_ee_ver"),
record.get("connection_type"),
record.get("connection_endpoint"),
+ record.get("agent_vnc_password"),
record.get("ssl_key_fingerprint"),
record.get("token_version") or 1,
record.get("status") or "active",
diff --git a/Data/Engine/server.py b/Data/Engine/server.py
index f11cb605..f4632e44 100644
--- a/Data/Engine/server.py
+++ b/Data/Engine/server.py
@@ -128,15 +128,14 @@ 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
+ vnc_port: int
+ vnc_ws_host: str
+ vnc_ws_port: int
+ vnc_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
+ vnc_proxy: Optional[Any] = None
+ vnc_registry: Optional[Any] = None
__all__ = ["EngineContext", "create_app", "register_engine_api"]
@@ -162,11 +161,10 @@ 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,
+ vnc_port=settings.vnc_port,
+ vnc_ws_host=settings.vnc_ws_host,
+ vnc_ws_port=settings.vnc_ws_port,
+ vnc_session_ttl_seconds=settings.vnc_session_ttl_seconds,
assembly_cache=None,
)
diff --git a/Data/Engine/services/API/__init__.py b/Data/Engine/services/API/__init__.py
index cc840f02..e1f4e76d 100644
--- a/Data/Engine/services/API/__init__.py
+++ b/Data/Engine/services/API/__init__.py
@@ -33,7 +33,8 @@ 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 .devices.vnc import register_vnc
+from .devices.shell import register_shell
from ...server import EngineContext
from .access_management.login import register_auth
@@ -292,7 +293,8 @@ 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)
+ register_vnc(app, adapters)
+ register_shell(app, adapters)
def _register_filters(app: Flask, adapters: EngineServiceAdapters) -> None:
filters_management.register_filters(app, adapters)
diff --git a/Data/Engine/services/API/devices/routes.py b/Data/Engine/services/API/devices/routes.py
index 9b741bb1..9fadd459 100644
--- a/Data/Engine/services/API/devices/routes.py
+++ b/Data/Engine/services/API/devices/routes.py
@@ -5,6 +5,7 @@
# API Endpoints (if applicable):
# - POST /api/agent/heartbeat (Device Authenticated) - Updates device last-seen metadata and inventory snapshots.
# - POST /api/agent/script/request (Device Authenticated) - Provides script execution payloads or idle signals to agents.
+# - POST /api/agent/vpn/ensure (Device Authenticated) - Ensures persistent WireGuard tunnel material.
# ======================================================
"""Device-affiliated agent endpoints for the Borealis Engine runtime."""
@@ -13,12 +14,14 @@ from __future__ import annotations
import json
import sqlite3
import time
+from urllib.parse import urlsplit
from typing import TYPE_CHECKING, Any, Dict, Optional
from flask import Blueprint, jsonify, request, g
from ....auth.device_auth import AGENT_CONTEXT_HEADER, require_device_auth
from ....auth.guid_utils import normalize_guid
+from .tunnel import _get_tunnel_service
if TYPE_CHECKING: # pragma: no cover - typing aide
from .. import EngineServiceAdapters
@@ -42,6 +45,20 @@ def _json_or_none(value: Any) -> Optional[str]:
return None
+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 register_agents(app, adapters: "EngineServiceAdapters") -> None:
"""Register agent heartbeat and script polling routes."""
@@ -218,4 +235,76 @@ def register_agents(app, adapters: "EngineServiceAdapters") -> None:
}
)
+ @blueprint.route("/api/agent/vpn/ensure", methods=["POST"])
+ @require_device_auth(auth_manager)
+ def vpn_ensure():
+ ctx = _auth_context()
+ if ctx is None:
+ return jsonify({"error": "auth_context_missing"}), 500
+
+ body = request.get_json(silent=True) or {}
+ requested_agent = (body.get("agent_id") or "").strip()
+ guid = normalize_guid(ctx.guid)
+
+ conn = db_conn_factory()
+ resolved_agent = ""
+ try:
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT agent_id FROM devices WHERE UPPER(guid) = ? ORDER BY last_seen DESC LIMIT 1",
+ (guid,),
+ )
+ row = cur.fetchone()
+ if row and row[0]:
+ resolved_agent = str(row[0]).strip()
+ finally:
+ conn.close()
+
+ if not resolved_agent:
+ log("VPN_Tunnel/tunnel", f"vpn_agent_ensure_missing_agent guid={guid}", _context_hint(ctx), level="ERROR")
+ return jsonify({"error": "agent_id_missing"}), 404
+
+ if requested_agent and requested_agent != resolved_agent:
+ log(
+ "VPN_Tunnel/tunnel",
+ "vpn_agent_ensure_agent_mismatch requested={0} resolved={1}".format(
+ requested_agent, resolved_agent
+ ),
+ _context_hint(ctx),
+ level="WARNING",
+ )
+
+ try:
+ tunnel_service = _get_tunnel_service(adapters)
+ endpoint_host = _infer_endpoint_host(request)
+ log(
+ "VPN_Tunnel/tunnel",
+ "vpn_agent_ensure_request agent_id={0} endpoint_host={1}".format(
+ resolved_agent, endpoint_host or "-"
+ ),
+ _context_hint(ctx),
+ )
+ payload = tunnel_service.connect(
+ agent_id=resolved_agent,
+ operator_id=None,
+ endpoint_host=endpoint_host,
+ )
+ except Exception as exc:
+ log(
+ "VPN_Tunnel/tunnel",
+ "vpn_agent_ensure_failed agent_id={0} error={1}".format(resolved_agent, str(exc)),
+ _context_hint(ctx),
+ level="ERROR",
+ )
+ return jsonify({"error": "tunnel_start_failed", "detail": str(exc)}), 500
+
+ log(
+ "VPN_Tunnel/tunnel",
+ "vpn_agent_ensure_response agent_id={0} tunnel_id={1}".format(
+ payload.get("agent_id", resolved_agent), payload.get("tunnel_id", "-")
+ ),
+ _context_hint(ctx),
+ )
+ return jsonify(payload), 200
+
app.register_blueprint(blueprint)
diff --git a/Data/Engine/services/API/devices/rdp.py b/Data/Engine/services/API/devices/shell.py
similarity index 51%
rename from Data/Engine/services/API/devices/rdp.py
rename to Data/Engine/services/API/devices/shell.py
index be917cd6..4a98e2d9 100644
--- a/Data/Engine/services/API/devices/rdp.py
+++ b/Data/Engine/services/API/devices/shell.py
@@ -1,12 +1,13 @@
# ======================================================
-# Data\Engine\services\API\devices\rdp.py
-# Description: RDP session bootstrap for Guacamole WebSocket tunnels.
+# Data\Engine\services\API\devices\shell.py
+# Description: Remote PowerShell session endpoints for persistent WireGuard tunnels.
#
# API Endpoints (if applicable):
-# - POST /api/rdp/session (Token Authenticated) - Issues a one-time Guacamole tunnel token for RDP.
+# - POST /api/shell/establish (Token Authenticated) - Ensure shell readiness over WireGuard.
+# - POST /api/shell/disconnect (Token Authenticated) - Disconnect the operator shell session.
# ======================================================
-"""RDP session bootstrap endpoints for the Borealis Engine."""
+"""Remote PowerShell session endpoints for the Borealis Engine."""
from __future__ import annotations
import os
@@ -16,7 +17,6 @@ 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
@@ -81,25 +81,18 @@ def _infer_endpoint_host(req) -> str:
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")
+def register_shell(app, adapters: "EngineServiceAdapters") -> None:
+ blueprint = Blueprint("vpn_shell", __name__)
+ logger = adapters.context.logger.getChild("vpn_shell.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)
+ service_log("VPN_Tunnel/remote_shell", message, level=level)
except Exception:
- logger.debug("rdp service log write failed", exc_info=True)
+ logger.debug("vpn_shell service log write failed", exc_info=True)
def _request_remote() -> str:
forwarded = (request.headers.get("X-Forwarded-For") or "").strip()
@@ -107,8 +100,8 @@ def register_rdp(app, adapters: "EngineServiceAdapters") -> None:
return forwarded.split(",")[0].strip()
return (request.remote_addr or "").strip()
- @blueprint.route("/api/rdp/session", methods=["POST"])
- def rdp_session():
+ @blueprint.route("/api/shell/establish", methods=["POST"])
+ def shell_establish():
requirement = _require_login(app)
if requirement:
payload, status = requirement
@@ -119,77 +112,82 @@ def register_rdp(app, adapters: "EngineServiceAdapters") -> 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
+
+ try:
+ tunnel_service = _get_tunnel_service(adapters)
+ endpoint_host = _infer_endpoint_host(request)
+ _service_log_event(
+ "vpn_shell_establish_request agent_id={0} operator={1} endpoint_host={2} remote={3}".format(
+ agent_id,
+ operator_id or "-",
+ endpoint_host or "-",
+ _request_remote() or "-",
+ )
+ )
+ payload = tunnel_service.connect(
+ agent_id=agent_id,
+ operator_id=operator_id,
+ endpoint_host=endpoint_host,
+ )
+ except Exception as exc:
+ _service_log_event(
+ "vpn_shell_establish_failed agent_id={0} operator={1} error={2}".format(
+ agent_id,
+ operator_id or "-",
+ str(exc),
+ ),
+ level="ERROR",
+ )
+ return jsonify({"error": "establish_failed", "detail": str(exc)}), 500
+
+ agent_socket = False
+ registry = getattr(adapters.context, "agent_socket_registry", None)
+ if registry and hasattr(registry, "is_registered"):
+ try:
+ agent_socket = bool(registry.is_registered(agent_id))
+ except Exception:
+ agent_socket = False
+
+ response = dict(payload)
+ response["status"] = "ok"
+ response["agent_socket"] = agent_socket
+ _service_log_event(
+ "vpn_shell_establish_response agent_id={0} tunnel_id={1} agent_socket={2}".format(
+ agent_id,
+ response.get("tunnel_id", "-"),
+ str(agent_socket).lower(),
+ )
+ )
+ return jsonify(response), 200
+
+ @blueprint.route("/api/shell/disconnect", methods=["POST"])
+ def shell_disconnect():
+ requirement = _require_login(app)
+ if requirement:
+ payload, status = requirement
+ return jsonify(payload), status
+
+ body = request.get_json(silent=True) or {}
+ agent_id = _normalize_text(body.get("agent_id"))
+ reason = _normalize_text(body.get("reason") or "operator_disconnect")
+ operator_id = (_current_user(app) or {}).get("username") or None
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(
+ "vpn_shell_disconnect_request agent_id={0} operator={1} reason={2} remote={3}".format(
agent_id,
operator_id or "-",
- protocol,
+ reason or "-",
_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,
- )
+ return jsonify({"status": "disconnected", "reason": reason}), 200
app.register_blueprint(blueprint)
+
+
+__all__ = ["register_shell"]
diff --git a/Data/Engine/services/API/devices/tunnel.py b/Data/Engine/services/API/devices/tunnel.py
index 16947cce..e6ab538d 100644
--- a/Data/Engine/services/API/devices/tunnel.py
+++ b/Data/Engine/services/API/devices/tunnel.py
@@ -1,12 +1,11 @@
# ======================================================
# Data\Engine\services\API\devices\tunnel.py
-# Description: WireGuard VPN tunnel API (connect/status/disconnect).
+# Description: WireGuard VPN tunnel API (connect/status).
#
# API Endpoints (if applicable):
# - POST /api/tunnel/connect (Token Authenticated) - Issues VPN session material for an agent.
# - GET /api/tunnel/status (Token Authenticated) - Returns VPN status for an agent.
# - GET /api/tunnel/active (Token Authenticated) - Lists active VPN tunnel sessions.
-# - DELETE /api/tunnel/disconnect (Token Authenticated) - Tears down VPN session for an agent.
# ======================================================
"""WireGuard VPN tunnel API (Engine side)."""
@@ -254,52 +253,4 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
)
return jsonify({"count": len(sessions), "tunnels": sessions}), 200
- @blueprint.route("/api/tunnel/disconnect", methods=["DELETE"])
- def disconnect_tunnel():
- requirement = _require_login(app)
- if requirement:
- payload, status = requirement
- return jsonify(payload), status
-
- body = request.get_json(silent=True) or {}
- agent_id = _normalize_text(body.get("agent_id"))
- tunnel_id = _normalize_text(body.get("tunnel_id"))
- reason = _normalize_text(body.get("reason") or "operator_stop")
-
- tunnel_service = _get_tunnel_service(adapters)
- _service_log_event(
- "vpn_api_disconnect_request agent_id={0} tunnel_id={1} reason={2} operator={3} remote={4}".format(
- agent_id or "-",
- tunnel_id or "-",
- reason or "-",
- (_current_user(app) or {}).get("username") or "-",
- _request_remote() or "-",
- )
- )
- stopped = False
- if tunnel_id:
- stopped = tunnel_service.disconnect_by_tunnel(tunnel_id, reason=reason)
- elif agent_id:
- stopped = tunnel_service.disconnect(agent_id, reason=reason)
- else:
- return jsonify({"error": "agent_id_required"}), 400
-
- if not stopped:
- _service_log_event(
- "vpn_api_disconnect_not_found agent_id={0} tunnel_id={1}".format(
- agent_id or "-",
- tunnel_id or "-",
- ),
- level="WARNING",
- )
- return jsonify({"error": "not_found"}), 404
-
- _service_log_event(
- "vpn_api_disconnect_response agent_id={0} tunnel_id={1} status=stopped".format(
- agent_id or "-",
- tunnel_id or "-",
- )
- )
- return jsonify({"status": "stopped", "reason": reason}), 200
-
app.register_blueprint(blueprint)
diff --git a/Data/Engine/services/API/devices/vnc.py b/Data/Engine/services/API/devices/vnc.py
new file mode 100644
index 00000000..77c802d3
--- /dev/null
+++ b/Data/Engine/services/API/devices/vnc.py
@@ -0,0 +1,338 @@
+# ======================================================
+# Data\Engine\services\API\devices\vnc.py
+# Description: VNC session bootstrap for noVNC WebSocket tunnels.
+#
+# API Endpoints (if applicable):
+# - POST /api/vnc/establish (Token Authenticated) - Establish a VNC session for noVNC.
+# - POST /api/vnc/disconnect (Token Authenticated) - Disconnect the operator VNC session.
+# - POST /api/vnc/session (Token Authenticated) - Legacy alias for establish.
+# ======================================================
+
+"""VNC session bootstrap endpoints for the Borealis Engine."""
+from __future__ import annotations
+
+import os
+import secrets
+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.vnc_proxy import VNC_WS_PATH, ensure_vnc_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 _generate_vnc_password() -> str:
+ # UltraVNC uses the first 8 characters for VNC auth; keep the token to 8 for compatibility.
+ return secrets.token_hex(4)
+
+
+def _load_vnc_password(adapters: "EngineServiceAdapters", agent_id: str) -> Optional[str]:
+ conn = adapters.db_conn_factory()
+ try:
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT agent_vnc_password FROM devices WHERE agent_id=? ORDER BY last_seen DESC LIMIT 1",
+ (agent_id,),
+ )
+ row = cur.fetchone()
+ if row and row[0]:
+ return str(row[0]).strip()
+ except Exception:
+ adapters.context.logger.debug("Failed to load agent VNC password", exc_info=True)
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+ return None
+
+
+def _store_vnc_password(adapters: "EngineServiceAdapters", agent_id: str, password: str) -> None:
+ conn = adapters.db_conn_factory()
+ try:
+ cur = conn.cursor()
+ cur.execute(
+ "UPDATE devices SET agent_vnc_password=? WHERE agent_id=?",
+ (password, agent_id),
+ )
+ conn.commit()
+ except Exception:
+ adapters.context.logger.debug("Failed to store agent VNC password", exc_info=True)
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+
+
+def register_vnc(app, adapters: "EngineServiceAdapters") -> None:
+ blueprint = Blueprint("vnc", __name__)
+ logger = adapters.context.logger.getChild("vnc.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("VNC", message, level=level)
+ except Exception:
+ logger.debug("vnc 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()
+
+ def _issue_session(agent_id: str, operator_id: Optional[str]) -> Tuple[Dict[str, Any], int]:
+ tunnel_service = _get_tunnel_service(adapters)
+ session_payload = tunnel_service.session_payload(agent_id, include_token=False)
+ if not session_payload:
+ try:
+ session_payload = tunnel_service.connect(
+ agent_id=agent_id,
+ operator_id=operator_id,
+ endpoint_host=_infer_endpoint_host(request),
+ )
+ except Exception:
+ return {"error": "tunnel_down"}, 409
+
+ vnc_port = int(getattr(adapters.context, "vnc_port", 5900))
+ raw_ports = session_payload.get("allowed_ports") or []
+ allowed_ports = []
+ for value in raw_ports:
+ try:
+ allowed_ports.append(int(value))
+ except Exception:
+ continue
+ if vnc_port not in allowed_ports:
+ return {"error": "vnc_not_allowed", "vnc_port": vnc_port}, 403
+
+ virtual_ip = _normalize_text(session_payload.get("virtual_ip"))
+ host = virtual_ip.split("/")[0] if virtual_ip else ""
+ if not host:
+ return {"error": "virtual_ip_missing"}, 500
+
+ vnc_password = _load_vnc_password(adapters, agent_id)
+ if not vnc_password:
+ vnc_password = _generate_vnc_password()
+ _store_vnc_password(adapters, agent_id, vnc_password)
+ if len(vnc_password) > 8:
+ vnc_password = vnc_password[:8]
+ _store_vnc_password(adapters, agent_id, vnc_password)
+
+ registry = ensure_vnc_proxy(adapters.context, logger=logger)
+ if registry is None:
+ return {"error": "vnc_proxy_unavailable"}, 503
+
+ _service_log_event(
+ "vnc_establish_request agent_id={0} operator={1} remote={2}".format(
+ agent_id,
+ operator_id or "-",
+ _request_remote() or "-",
+ )
+ )
+
+ vnc_session = registry.create(
+ agent_id=agent_id,
+ host=host,
+ port=vnc_port,
+ operator_id=operator_id,
+ )
+
+ emit_agent = getattr(adapters.context, "emit_agent_event", None)
+ payload = {
+ "agent_id": agent_id,
+ "port": vnc_port,
+ "allowed_ips": session_payload.get("allowed_ips"),
+ "virtual_ip": host,
+ "password": vnc_password,
+ "reason": "vnc_session_start",
+ }
+ agent_socket_ready = True
+ if callable(emit_agent):
+ agent_socket_ready = bool(emit_agent(agent_id, "vnc_start", payload))
+ if agent_socket_ready:
+ _service_log_event(
+ "vnc_start_emit agent_id={0} port={1} virtual_ip={2}".format(
+ agent_id,
+ vnc_port,
+ host or "-",
+ )
+ )
+ else:
+ _service_log_event(
+ "vnc_start_emit_failed agent_id={0} port={1}".format(
+ agent_id,
+ vnc_port,
+ ),
+ level="WARNING",
+ )
+ if not agent_socket_ready:
+ return {"error": "agent_socket_missing"}, 409
+
+ ws_scheme = "wss" if _is_secure(request) else "ws"
+ ws_host = _infer_endpoint_host(request)
+ ws_port = int(getattr(adapters.context, "vnc_ws_port", 4823))
+ ws_url = f"{ws_scheme}://{ws_host}:{ws_port}{VNC_WS_PATH}"
+
+ _service_log_event(
+ "vnc_session_ready agent_id={0} token={1} expires_at={2}".format(
+ agent_id,
+ vnc_session.token[:8],
+ int(vnc_session.expires_at),
+ )
+ )
+
+ return (
+ {
+ "token": vnc_session.token,
+ "ws_url": ws_url,
+ "expires_at": int(vnc_session.expires_at),
+ "virtual_ip": host,
+ "tunnel_id": session_payload.get("tunnel_id"),
+ "engine_virtual_ip": session_payload.get("engine_virtual_ip"),
+ "allowed_ports": session_payload.get("allowed_ports"),
+ "vnc_password": vnc_password,
+ "vnc_port": vnc_port,
+ },
+ 200,
+ )
+
+ @blueprint.route("/api/vnc/establish", methods=["POST"])
+ def vnc_establish():
+ 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"))
+
+ if not agent_id:
+ return jsonify({"error": "agent_id_required"}), 400
+
+ payload, status = _issue_session(agent_id, operator_id)
+ return jsonify(payload), status
+
+ @blueprint.route("/api/vnc/session", methods=["POST"])
+ def vnc_session():
+ return vnc_establish()
+
+ @blueprint.route("/api/vnc/disconnect", methods=["POST"])
+ def vnc_disconnect():
+ 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"))
+ reason = _normalize_text(body.get("reason") or "operator_disconnect")
+
+ if not agent_id:
+ return jsonify({"error": "agent_id_required"}), 400
+
+ registry = ensure_vnc_proxy(adapters.context, logger=logger)
+ revoked = 0
+ if registry is not None:
+ try:
+ revoked = registry.revoke_agent(agent_id)
+ except Exception:
+ revoked = 0
+
+ emit_agent = getattr(adapters.context, "emit_agent_event", None)
+ if callable(emit_agent):
+ try:
+ emit_agent(agent_id, "vnc_stop", {"agent_id": agent_id, "reason": reason})
+ except Exception:
+ pass
+
+ _service_log_event(
+ "vnc_disconnect agent_id={0} operator={1} reason={2} revoked={3}".format(
+ agent_id,
+ operator_id or "-",
+ reason or "-",
+ revoked,
+ )
+ )
+
+ return jsonify({"status": "disconnected", "revoked": revoked, "reason": reason}), 200
+
+ app.register_blueprint(blueprint)
diff --git a/Data/Engine/services/RemoteDesktop/__init__.py b/Data/Engine/services/RemoteDesktop/__init__.py
index cf110fda..9989e0c1 100644
--- a/Data/Engine/services/RemoteDesktop/__init__.py
+++ b/Data/Engine/services/RemoteDesktop/__init__.py
@@ -1,9 +1,8 @@
# ======================================================
# Data\Engine\services\RemoteDesktop\__init__.py
-# Description: Remote desktop services (Guacamole proxy + session management).
+# Description: Remote desktop services (VNC proxy + session management).
#
# API Endpoints (if applicable): None
# ======================================================
"""Remote desktop service helpers for the Borealis Engine runtime."""
-
diff --git a/Data/Engine/services/RemoteDesktop/guacamole_proxy.py b/Data/Engine/services/RemoteDesktop/guacamole_proxy.py
deleted file mode 100644
index dccebb1e..00000000
--- a/Data/Engine/services/RemoteDesktop/guacamole_proxy.py
+++ /dev/null
@@ -1,369 +0,0 @@
-# ======================================================
-# Data\Engine\services\RemoteDesktop\guacamole_proxy.py
-# Description: Guacamole tunnel proxy (WebSocket -> guacd) for RDP sessions.
-#
-# API Endpoints (if applicable): None
-# ======================================================
-
-"""Guacamole WebSocket proxy that bridges browser tunnels to guacd."""
-from __future__ import annotations
-
-import asyncio
-import logging
-import ssl
-import threading
-import time
-import uuid
-from dataclasses import dataclass
-from typing import Any, Dict, Optional, Tuple
-from urllib.parse import parse_qs, urlsplit
-
-import websockets
-
-GUAC_WS_PATH = "/guacamole"
-_MAX_MESSAGE_SIZE = 100_000_000
-
-
-@dataclass
-class RdpSession:
- token: str
- agent_id: str
- host: str
- port: int
- protocol: str
- username: str
- password: str
- ignore_cert: bool
- created_at: float
- expires_at: float
- operator_id: Optional[str] = None
- domain: Optional[str] = None
- security: Optional[str] = None
-
-
-class RdpSessionRegistry:
- def __init__(self, ttl_seconds: int, logger: logging.Logger) -> None:
- self.ttl_seconds = max(30, int(ttl_seconds))
- self.logger = logger
- self._lock = threading.Lock()
- self._sessions: Dict[str, RdpSession] = {}
-
- def _cleanup(self, now: Optional[float] = None) -> None:
- current = now if now is not None else time.time()
- expired = [token for token, session in self._sessions.items() if session.expires_at <= current]
- for token in expired:
- self._sessions.pop(token, None)
-
- def create(
- self,
- *,
- agent_id: str,
- host: str,
- port: int,
- username: str,
- password: str,
- protocol: str = "rdp",
- ignore_cert: bool = True,
- operator_id: Optional[str] = None,
- domain: Optional[str] = None,
- security: Optional[str] = None,
- ) -> RdpSession:
- token = uuid.uuid4().hex
- now = time.time()
- expires_at = now + self.ttl_seconds
- session = RdpSession(
- token=token,
- agent_id=agent_id,
- host=host,
- port=port,
- protocol=protocol,
- username=username,
- password=password,
- ignore_cert=ignore_cert,
- created_at=now,
- expires_at=expires_at,
- operator_id=operator_id,
- domain=domain,
- security=security,
- )
- with self._lock:
- self._cleanup(now)
- self._sessions[token] = session
- return session
-
- def consume(self, token: str) -> Optional[RdpSession]:
- if not token:
- return None
- with self._lock:
- self._cleanup()
- session = self._sessions.pop(token, None)
- return session
-
-
-class GuacamoleProxyServer:
- def __init__(
- self,
- *,
- host: str,
- port: int,
- guacd_host: str,
- guacd_port: int,
- registry: RdpSessionRegistry,
- logger: logging.Logger,
- ssl_context: Optional[ssl.SSLContext] = None,
- ) -> None:
- self.host = host
- self.port = port
- self.guacd_host = guacd_host
- self.guacd_port = guacd_port
- self.registry = registry
- self.logger = logger
- self.ssl_context = ssl_context
- self._thread: Optional[threading.Thread] = None
- self._ready = threading.Event()
- self._failed = threading.Event()
-
- def ensure_started(self, timeout: float = 3.0) -> bool:
- if self._thread and self._thread.is_alive():
- return not self._failed.is_set()
- self._failed.clear()
- self._ready.clear()
- self._thread = threading.Thread(target=self._run, daemon=True)
- self._thread.start()
- self._ready.wait(timeout)
- return not self._failed.is_set()
-
- def _run(self) -> None:
- try:
- asyncio.run(self._serve())
- except Exception as exc:
- self._failed.set()
- self.logger.error("Guacamole proxy server failed: %s", exc)
- self._ready.set()
-
- async def _serve(self) -> None:
- self.logger.info(
- "Starting Guacamole proxy on %s:%s (guacd %s:%s)",
- self.host,
- self.port,
- self.guacd_host,
- self.guacd_port,
- )
- try:
- server = await websockets.serve(
- self._handle_client,
- self.host,
- self.port,
- ssl=self.ssl_context,
- max_size=_MAX_MESSAGE_SIZE,
- ping_interval=20,
- ping_timeout=20,
- )
- except Exception:
- self._failed.set()
- self._ready.set()
- raise
- self._ready.set()
- await server.wait_closed()
-
- async def _handle_client(self, websocket, path: str) -> None:
- parsed = urlsplit(path)
- if parsed.path != GUAC_WS_PATH:
- await websocket.close(code=1008, reason="invalid_path")
- return
- query = parse_qs(parsed.query or "")
- token = (query.get("token") or [""])[0]
- session = self.registry.consume(token)
- if not session:
- await websocket.close(code=1008, reason="invalid_session")
- return
-
- logger = self.logger.getChild("session")
- logger.info("Guacamole session start agent_id=%s protocol=%s", session.agent_id, session.protocol)
-
- try:
- reader, writer = await asyncio.open_connection(self.guacd_host, self.guacd_port)
- except Exception as exc:
- logger.warning("guacd connect failed: %s", exc)
- await websocket.close(code=1011, reason="guacd_unavailable")
- return
-
- try:
- await self._perform_handshake(reader, writer, session)
- except Exception as exc:
- logger.warning("guacd handshake failed: %s", exc)
- try:
- writer.close()
- await writer.wait_closed()
- except Exception:
- pass
- await websocket.close(code=1011, reason="handshake_failed")
- return
-
- async def _ws_to_guacd() -> None:
- try:
- async for message in websocket:
- if message is None:
- break
- if isinstance(message, str):
- data = message.encode("utf-8")
- else:
- data = bytes(message)
- writer.write(data)
- await writer.drain()
- finally:
- try:
- writer.close()
- except Exception:
- pass
-
- async def _guacd_to_ws() -> None:
- try:
- while True:
- data = await reader.read(8192)
- if not data:
- break
- await websocket.send(data.decode("utf-8", errors="ignore"))
- finally:
- try:
- await websocket.close()
- except Exception:
- pass
-
- await asyncio.wait(
- [asyncio.create_task(_ws_to_guacd()), asyncio.create_task(_guacd_to_ws())],
- return_when=asyncio.FIRST_COMPLETED,
- )
-
- logger.info("Guacamole session ended agent_id=%s", session.agent_id)
-
- async def _perform_handshake(self, reader, writer, session: RdpSession) -> None:
- writer.write(_encode_instruction("select", session.protocol))
- await writer.drain()
-
- buffer = b""
- args = None
- deadline = time.time() + 8
-
- while time.time() < deadline:
- parts, buffer = await _read_instruction(reader, buffer)
- if not parts:
- continue
- op = parts[0]
- if op == "args":
- args = parts[1:]
- break
- if op == "error":
- raise RuntimeError("guacd_error:" + " ".join(parts[1:]))
- if not args:
- raise RuntimeError("guacd_args_timeout")
-
- params = {
- "hostname": session.host,
- "port": str(session.port),
- "username": session.username or "",
- "password": session.password or "",
- }
- if session.domain:
- params["domain"] = session.domain
- if session.security:
- params["security"] = session.security
- if session.ignore_cert:
- params["ignore-cert"] = "true"
-
- values = [params.get(name, "") for name in args]
- writer.write(_encode_instruction("connect", *values))
- await writer.drain()
-
-
-def _encode_instruction(*elements: str) -> bytes:
- parts = []
- for element in elements:
- text = "" if element is None else str(element)
- parts.append(f"{len(text)}.{text}".encode("utf-8"))
- return b",".join(parts) + b";"
-
-
-def _parse_instruction(raw: bytes) -> Tuple[str, ...]:
- parts = []
- idx = 0
- length = len(raw)
- while idx < length:
- dot = raw.find(b".", idx)
- if dot < 0:
- break
- try:
- element_len = int(raw[idx:dot].decode("ascii") or "0")
- except Exception:
- break
- start = dot + 1
- end = start + element_len
- if end > length:
- break
- parts.append(raw[start:end].decode("utf-8", errors="ignore"))
- idx = end
- if idx < length and raw[idx:idx + 1] == b",":
- idx += 1
- return tuple(parts)
-
-
-async def _read_instruction(reader, buffer: bytes) -> Tuple[Tuple[str, ...], bytes]:
- while b";" not in buffer:
- chunk = await reader.read(4096)
- if not chunk:
- break
- buffer += chunk
- if b";" not in buffer:
- return tuple(), buffer
- instruction, remainder = buffer.split(b";", 1)
- if not instruction:
- return tuple(), remainder
- return _parse_instruction(instruction), remainder
-
-
-def _build_ssl_context(cert_path: Optional[str], key_path: Optional[str]) -> Optional[ssl.SSLContext]:
- if not cert_path or not key_path:
- return None
- try:
- context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
- context.load_cert_chain(certfile=cert_path, keyfile=key_path)
- return context
- except Exception:
- return None
-
-
-def ensure_guacamole_proxy(context: Any, *, logger: Optional[logging.Logger] = None) -> Optional[RdpSessionRegistry]:
- if logger is None:
- logger = context.logger if hasattr(context, "logger") else logging.getLogger("borealis.engine.rdp")
-
- registry = getattr(context, "rdp_registry", None)
- if registry is None:
- ttl = int(getattr(context, "rdp_session_ttl_seconds", 120))
- registry = RdpSessionRegistry(ttl_seconds=ttl, logger=logger)
- setattr(context, "rdp_registry", registry)
-
- proxy = getattr(context, "rdp_proxy", None)
- if proxy is None:
- cert_path = getattr(context, "tls_bundle_path", None) or getattr(context, "tls_cert_path", None)
- ssl_context = _build_ssl_context(
- cert_path,
- getattr(context, "tls_key_path", None),
- )
- proxy = GuacamoleProxyServer(
- host=str(getattr(context, "rdp_ws_host", "0.0.0.0")),
- port=int(getattr(context, "rdp_ws_port", 4823)),
- guacd_host=str(getattr(context, "guacd_host", "127.0.0.1")),
- guacd_port=int(getattr(context, "guacd_port", 4822)),
- registry=registry,
- logger=logger.getChild("guacamole_proxy"),
- ssl_context=ssl_context,
- )
- setattr(context, "rdp_proxy", proxy)
-
- if not proxy.ensure_started():
- logger.error("Guacamole proxy failed to start; RDP sessions unavailable.")
- return None
- return registry
-
-
-__all__ = ["GUAC_WS_PATH", "RdpSessionRegistry", "GuacamoleProxyServer", "ensure_guacamole_proxy"]
diff --git a/Data/Engine/services/RemoteDesktop/vnc_proxy.py b/Data/Engine/services/RemoteDesktop/vnc_proxy.py
new file mode 100644
index 00000000..44bad107
--- /dev/null
+++ b/Data/Engine/services/RemoteDesktop/vnc_proxy.py
@@ -0,0 +1,285 @@
+# ======================================================
+# Data\Engine\services\RemoteDesktop\vnc_proxy.py
+# Description: VNC tunnel proxy (WebSocket -> TCP) for noVNC sessions.
+#
+# API Endpoints (if applicable): None
+# ======================================================
+
+"""VNC WebSocket proxy that bridges browser sessions to agent VNC servers."""
+from __future__ import annotations
+
+import asyncio
+import logging
+import ssl
+import threading
+import time
+import uuid
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, Optional, Tuple
+from urllib.parse import parse_qs, urlsplit
+
+import websockets
+
+VNC_WS_PATH = "/vnc"
+_MAX_MESSAGE_SIZE = 100_000_000
+
+
+@dataclass
+class VncSession:
+ token: str
+ agent_id: str
+ host: str
+ port: int
+ created_at: float
+ expires_at: float
+ operator_id: Optional[str] = None
+
+
+class VncSessionRegistry:
+ 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, VncSession] = {}
+
+ 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,
+ operator_id: Optional[str] = None,
+ ) -> VncSession:
+ token = uuid.uuid4().hex
+ now = time.time()
+ expires_at = now + self.ttl_seconds
+ session = VncSession(
+ token=token,
+ agent_id=agent_id,
+ host=host,
+ port=port,
+ created_at=now,
+ expires_at=expires_at,
+ operator_id=operator_id,
+ )
+ with self._lock:
+ self._cleanup(now)
+ self._sessions[token] = session
+ return session
+
+ def consume(self, token: str) -> Optional[VncSession]:
+ if not token:
+ return None
+ with self._lock:
+ self._cleanup()
+ session = self._sessions.pop(token, None)
+ return session
+
+ def revoke_agent(self, agent_id: str) -> int:
+ if not agent_id:
+ return 0
+ removed = 0
+ with self._lock:
+ self._cleanup()
+ tokens = [token for token, session in self._sessions.items() if session.agent_id == agent_id]
+ for token in tokens:
+ if self._sessions.pop(token, None):
+ removed += 1
+ return removed
+
+
+class VncProxyServer:
+ def __init__(
+ self,
+ *,
+ host: str,
+ port: int,
+ registry: VncSessionRegistry,
+ logger: logging.Logger,
+ emit_agent_event: Optional[Callable[[str, str, Any], bool]] = None,
+ ssl_context: Optional[ssl.SSLContext] = None,
+ ) -> None:
+ self.host = host
+ self.port = port
+ self.registry = registry
+ self.logger = logger
+ self._emit_agent_event = emit_agent_event
+ 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("VNC proxy server failed: %s", exc)
+ self._ready.set()
+
+ async def _serve(self) -> None:
+ self.logger.info("Starting VNC proxy on %s:%s", self.host, self.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 != VNC_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("VNC session start agent_id=%s", session.agent_id)
+
+ try:
+ try:
+ reader, writer = await self._connect_vnc(session.host, session.port)
+ except Exception as exc:
+ logger.warning("VNC connect failed: %s", exc)
+ await websocket.close(code=1011, reason="vnc_unavailable")
+ return
+
+ async def _ws_to_tcp() -> 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 _tcp_to_ws() -> None:
+ try:
+ while True:
+ data = await reader.read(8192)
+ if not data:
+ break
+ await websocket.send(data)
+ finally:
+ try:
+ await websocket.close()
+ except Exception:
+ pass
+
+ await asyncio.wait(
+ [asyncio.create_task(_ws_to_tcp()), asyncio.create_task(_tcp_to_ws())],
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ finally:
+ logger.info("VNC session ended agent_id=%s", session.agent_id)
+ self._notify_agent_session_end(session, reason="vnc_session_end")
+
+ async def _connect_vnc(self, host: str, port: int) -> Tuple[Any, Any]:
+ attempts = 5
+ delay = 0.5
+ last_exc: Optional[Exception] = None
+ for attempt in range(attempts):
+ try:
+ return await asyncio.open_connection(host, port)
+ except Exception as exc:
+ last_exc = exc
+ if attempt < attempts - 1:
+ await asyncio.sleep(delay)
+ if last_exc:
+ raise last_exc
+ raise RuntimeError("vnc_connect_failed")
+
+ def _notify_agent_session_end(self, session: VncSession, reason: str) -> None:
+ if not self._emit_agent_event:
+ return
+ payload = {"agent_id": session.agent_id, "reason": reason}
+ try:
+ self._emit_agent_event(session.agent_id, "vnc_stop", payload)
+ except Exception:
+ self.logger.debug("Failed to emit vnc_stop for agent_id=%s", session.agent_id, exc_info=True)
+
+
+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_vnc_proxy(context: Any, *, logger: Optional[logging.Logger] = None) -> Optional[VncSessionRegistry]:
+ if logger is None:
+ logger = context.logger if hasattr(context, "logger") else logging.getLogger("borealis.engine.vnc")
+
+ registry = getattr(context, "vnc_registry", None)
+ if registry is None:
+ ttl = int(getattr(context, "vnc_session_ttl_seconds", 120))
+ registry = VncSessionRegistry(ttl_seconds=ttl, logger=logger)
+ setattr(context, "vnc_registry", registry)
+
+ proxy = getattr(context, "vnc_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 = VncProxyServer(
+ host=str(getattr(context, "vnc_ws_host", "0.0.0.0")),
+ port=int(getattr(context, "vnc_ws_port", 4823)),
+ registry=registry,
+ logger=logger.getChild("vnc_proxy"),
+ emit_agent_event=getattr(context, "emit_agent_event", None),
+ ssl_context=ssl_context,
+ )
+ setattr(context, "vnc_proxy", proxy)
+
+ if not proxy.ensure_started():
+ logger.error("VNC proxy failed to start; VNC sessions unavailable.")
+ return None
+ return registry
+
+
+__all__ = ["VNC_WS_PATH", "VncSessionRegistry", "VncProxyServer", "ensure_vnc_proxy"]
diff --git a/Data/Engine/services/VPN/vpn_tunnel_service.py b/Data/Engine/services/VPN/vpn_tunnel_service.py
index 74e35bde..429a30e3 100644
--- a/Data/Engine/services/VPN/vpn_tunnel_service.py
+++ b/Data/Engine/services/VPN/vpn_tunnel_service.py
@@ -12,6 +12,7 @@ from __future__ import annotations
import base64
import ipaddress
import json
+import os
import threading
import time
import uuid
@@ -22,6 +23,13 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
from .wireguard_server import WireGuardServerManager
+def _env_flag(name: str, *, default: bool) -> bool:
+ value = os.environ.get(name)
+ if value is None:
+ return default
+ return str(value).strip().lower() not in ("0", "false", "no", "off")
+
+
@dataclass
class VpnSession:
tunnel_id: str
@@ -62,14 +70,17 @@ class VpnTunnelService:
self.logger = context.logger.getChild("vpn_tunnel")
self.activity_logger = self.wg.logger.getChild("device_activity")
self.idle_seconds = max(60, int(idle_seconds))
+ self.persistent = _env_flag("BOREALIS_WIREGUARD_PERSISTENT", default=True)
self._lock = threading.Lock()
self._sessions_by_agent: Dict[str, VpnSession] = {}
self._sessions_by_tunnel: Dict[str, VpnSession] = {}
self._engine_ip = ipaddress.ip_interface(context.wireguard_engine_virtual_ip)
self._peer_network = ipaddress.ip_network(context.wireguard_peer_network, strict=False)
self._cleanup_listener()
- self._idle_thread = threading.Thread(target=self._idle_loop, daemon=True)
- self._idle_thread.start()
+ self._idle_thread: Optional[threading.Thread] = None
+ if not self.persistent:
+ self._idle_thread = threading.Thread(target=self._idle_loop, daemon=True)
+ self._idle_thread.start()
def _idle_loop(self) -> None:
while True:
@@ -90,7 +101,7 @@ class VpnTunnelService:
self.idle_seconds,
)
)
- self.disconnect(session.agent_id, reason="idle_timeout")
+ self.disconnect(session.agent_id, reason="idle_timeout", force=True)
def _allocate_virtual_ip(self, agent_id: str) -> str:
existing = self._sessions_by_agent.get(agent_id)
@@ -226,12 +237,12 @@ class VpnTunnelService:
self.logger.debug("Failed to write vpn_tunnel service log entry", exc_info=True)
def _cleanup_listener(self) -> None:
- try:
- self.wg.stop_listener(ignore_missing=True)
- self._service_log_event("vpn_listener_cleanup reason=startup")
- except Exception:
- self.logger.debug("Failed to clean up WireGuard listener on startup.", exc_info=True)
- self._service_log_event("vpn_listener_cleanup_failed reason=startup", level="WARNING")
+ self._service_log_event("vpn_listener_cleanup_skipped reason=startup")
+
+ def _is_soft_disconnect(self, reason: Optional[str]) -> bool:
+ if not reason:
+ return False
+ return str(reason).lower() in ("operator_disconnect", "component_unmount")
def _refresh_listener(self) -> None:
peers: List[Mapping[str, object]] = []
@@ -432,15 +443,60 @@ class VpnTunnelService:
except Exception:
self.logger.debug("vpn_tunnel_activity emit failed for agent_id=%s", agent_id, exc_info=True)
- def disconnect(self, agent_id: str, reason: str = "operator_stop") -> bool:
+ def disconnect(
+ self,
+ agent_id: str,
+ reason: str = "operator_stop",
+ *,
+ operator_id: Optional[str] = None,
+ force: bool = False,
+ ) -> bool:
with self._lock:
- session = self._sessions_by_agent.pop(agent_id, None)
+ session = self._sessions_by_agent.get(agent_id)
if not session:
self._service_log_event(
"vpn_tunnel_disconnect_missing agent_id={0} reason={1}".format(agent_id or "-", reason or "-")
)
return False
- self._sessions_by_tunnel.pop(session.tunnel_id, None)
+ if self.persistent and not force:
+ if operator_id:
+ try:
+ session.operator_ids.discard(operator_id)
+ except Exception:
+ pass
+ session.last_activity = time.time()
+ operator_text = ",".join(sorted(filter(None, session.operator_ids))) or "-"
+ self._service_log_event(
+ "vpn_tunnel_keepalive agent_id={0} tunnel_id={1} reason={2} operators={3}".format(
+ session.agent_id,
+ session.tunnel_id,
+ reason or "-",
+ operator_text,
+ )
+ )
+ return True
+ if not force and self._is_soft_disconnect(reason):
+ if operator_id:
+ try:
+ session.operator_ids.discard(operator_id)
+ except Exception:
+ pass
+ session.last_activity = time.time()
+ operator_text = ",".join(sorted(filter(None, session.operator_ids))) or "-"
+ self._service_log_event(
+ "vpn_tunnel_keepalive agent_id={0} tunnel_id={1} reason={2} operators={3}".format(
+ session.agent_id,
+ session.tunnel_id,
+ reason or "-",
+ operator_text,
+ )
+ )
+ return True
+ session = self._sessions_by_agent.pop(agent_id, None)
+ if session:
+ self._sessions_by_tunnel.pop(session.tunnel_id, None)
+ else:
+ return False
try:
self.wg.remove_firewall_rules(session.firewall_rules)
@@ -461,7 +517,14 @@ class VpnTunnelService:
self._log_device_activity(session, event="stop", reason=reason)
return True
- def disconnect_by_tunnel(self, tunnel_id: str, reason: str = "operator_stop") -> bool:
+ def disconnect_by_tunnel(
+ self,
+ tunnel_id: str,
+ reason: str = "operator_stop",
+ *,
+ operator_id: Optional[str] = None,
+ force: bool = False,
+ ) -> bool:
with self._lock:
session = self._sessions_by_tunnel.get(tunnel_id)
if not session:
@@ -469,7 +532,7 @@ class VpnTunnelService:
"vpn_tunnel_disconnect_missing tunnel_id={0} reason={1}".format(tunnel_id or "-", reason or "-")
)
return False
- return self.disconnect(session.agent_id, reason=reason)
+ return self.disconnect(session.agent_id, reason=reason, operator_id=operator_id, force=force)
def _emit_start(self, payload: Mapping[str, Any]) -> None:
if not self.socketio:
@@ -704,7 +767,7 @@ class VpnTunnelService:
"server_public_key": self.wg.server_public_key,
"client_public_key": session.client_public_key,
"client_private_key": session.client_private_key,
- "idle_seconds": self.idle_seconds,
+ "idle_seconds": 0 if self.persistent else self.idle_seconds,
"allowed_ports": list(session.allowed_ports),
"connected_operators": len([o for o in session.operator_ids if o]),
}
@@ -729,6 +792,6 @@ class VpnTunnelService:
"last_activity_iso": self._ts_to_iso(session.last_activity),
"expires_at": int(session.expires_at),
"expires_at_iso": self._ts_to_iso(session.expires_at),
- "idle_seconds": self.idle_seconds,
+ "idle_seconds": 0 if self.persistent else self.idle_seconds,
"status": "up",
}
diff --git a/Data/Engine/services/VPN/wireguard_server.py b/Data/Engine/services/VPN/wireguard_server.py
index e23f44c1..6c3b783a 100644
--- a/Data/Engine/services/VPN/wireguard_server.py
+++ b/Data/Engine/services/VPN/wireguard_server.py
@@ -164,6 +164,25 @@ class WireGuardServerManager:
return match.group(1).upper()
return None
+ def _service_exists(self) -> bool:
+ code, _, _ = self._run_command(["sc.exe", "query", self._service_id()])
+ return code == 0
+
+ def _stop_service(self, *, timeout: int = 20) -> bool:
+ service_id = self._service_id()
+ state = self._query_service_state()
+ if not state:
+ return False
+ if state == "STOPPED":
+ return True
+ self._run_command(["sc.exe", "stop", service_id])
+ for _ in range(max(1, timeout)):
+ time.sleep(1)
+ state = self._query_service_state()
+ if state == "STOPPED":
+ return True
+ return False
+
def _ensure_service_display_name(self) -> None:
if not self._service_display_name:
return
@@ -172,9 +191,9 @@ class WireGuardServerManager:
if code != 0 and err:
self.logger.warning("Failed to set WireGuard service display name: %s", err)
- def _ensure_service_running(self) -> None:
+ def _ensure_service_running(self, *, timeout: int = 20) -> None:
service_id = self._service_id()
- for _ in range(6):
+ for _ in range(max(1, timeout)):
state = self._query_service_state()
if state == "RUNNING":
return
@@ -183,8 +202,20 @@ class WireGuardServerManager:
if code != 0:
self.logger.error("Failed to start WireGuard tunnel service %s err=%s", service_id, err)
break
+ if state in ("START_PENDING", "STOP_PENDING"):
+ time.sleep(1)
+ continue
time.sleep(1)
state = self._query_service_state()
+ if state == "START_PENDING":
+ self.logger.warning("WireGuard tunnel service still START_PENDING; attempting restart.")
+ self._stop_service(timeout=10)
+ self._run_command(["sc.exe", "start", service_id])
+ for _ in range(10):
+ time.sleep(1)
+ if self._query_service_state() == "RUNNING":
+ return
+ state = self._query_service_state()
raise RuntimeError(f"WireGuard tunnel service {service_id} failed to start (state={state})")
def _normalise_allowed_ports(
@@ -329,6 +360,7 @@ class WireGuardServerManager:
for idx, rule in enumerate(rules):
name = f"Borealis-WG-Agent-{peer.get('agent_id','')}-{idx}"
protocol = str(rule.get("protocol") or "TCP").upper()
+ self._run_command(["netsh", "advfirewall", "firewall", "delete", "rule", f"name={name}"])
args = [
"netsh",
"advfirewall",
@@ -374,8 +406,12 @@ class WireGuardServerManager:
config_path.write_text(rendered, encoding="utf-8")
self.logger.info("Rendered WireGuard config to %s", config_path)
- # Ensure old service is removed before re-installing.
- self.stop_listener()
+ if self._service_exists():
+ if not self._stop_service(timeout=20):
+ self.logger.warning("WireGuard tunnel service did not stop cleanly before restart.")
+ self._ensure_service_display_name()
+ self._ensure_service_running(timeout=25)
+ return
args = [self._wireguard_exe, "/installtunnelservice", str(config_path)]
code, out, err = self._run_command(args)
@@ -384,21 +420,22 @@ class WireGuardServerManager:
raise RuntimeError(f"WireGuard installtunnelservice failed: {err}")
self.logger.info("WireGuard listener installed (service=%s)", config_path.stem)
self._ensure_service_display_name()
- self._ensure_service_running()
+ self._ensure_service_running(timeout=25)
def stop_listener(self, *, ignore_missing: bool = False) -> None:
- """Stop and remove the WireGuard tunnel service."""
+ """Stop the WireGuard tunnel service (leave installed for reuse)."""
- args = [self._wireguard_exe, "/uninstalltunnelservice", self._service_name]
- code, out, err = self._run_command(args)
- if code != 0:
- err_text = " ".join([out or "", err or ""]).strip().lower()
- if ignore_missing and ("does not exist" in err_text or "not exist" in err_text):
+ if not self._service_exists():
+ if ignore_missing:
self.logger.info("WireGuard tunnel service already absent")
return
- self.logger.warning("Failed to uninstall WireGuard tunnel service code=%s err=%s", code, err)
- else:
- self.logger.info("WireGuard tunnel service removed")
+ self.logger.warning("WireGuard tunnel service not found during stop.")
+ return
+
+ if not self._stop_service(timeout=20):
+ self.logger.warning("WireGuard tunnel service did not stop cleanly.")
+ return
+ self.logger.info("WireGuard tunnel service stopped")
def build_firewall_rules(
self,
diff --git a/Data/Engine/services/WebSocket/vpn_shell.py b/Data/Engine/services/WebSocket/vpn_shell.py
index ac10f4c2..ef96b6a8 100644
--- a/Data/Engine/services/WebSocket/vpn_shell.py
+++ b/Data/Engine/services/WebSocket/vpn_shell.py
@@ -187,7 +187,10 @@ class VpnShellBridge:
existing.close()
status = service.status(agent_id)
if not status:
- return None
+ try:
+ status = service.connect(agent_id=agent_id, operator_id=None, endpoint_host=None)
+ except Exception:
+ return None
host = str(status.get("virtual_ip") or "").split("/")[0]
port = int(self.context.wireguard_shell_port)
tcp = None
diff --git a/Data/Engine/web-interface/package.json b/Data/Engine/web-interface/package.json
index 4d4b3ef9..13b38c56 100644
--- a/Data/Engine/web-interface/package.json
+++ b/Data/Engine/web-interface/package.json
@@ -18,8 +18,8 @@
"@mui/x-tree-view": "8.10.0",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
+ "@novnc/novnc": "^1.4.0",
"dayjs": "1.11.18",
- "guacamole-common-js": "1.5.0",
"normalize.css": "8.0.1",
"prismjs": "1.30.0",
"react-simple-code-editor": "0.13.1",
diff --git a/Data/Engine/web-interface/src/Devices/Device_Details.jsx b/Data/Engine/web-interface/src/Devices/Device_Details.jsx
index 00418d29..c11590b4 100644
--- a/Data/Engine/web-interface/src/Devices/Device_Details.jsx
+++ b/Data/Engine/web-interface/src/Devices/Device_Details.jsx
@@ -43,7 +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";
+import ReverseTunnelVnc from "./ReverseTunnel/VNC.jsx";
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -85,12 +85,6 @@ const buildVpnGroups = (shellPort) => {
description: "Web terminal access over the VPN tunnel.",
ports: [normalizedShell],
},
- {
- key: "rdp",
- label: "RDP",
- description: "Remote Desktop (TCP 3389).",
- ports: [3389],
- },
{
key: "winrm",
label: "WinRM",
@@ -121,7 +115,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 },
+ { key: "vnc", label: "Remote Desktop (VNC)", icon: DesktopWindowsRoundedIcon },
];
const myTheme = themeQuartz.withParams({
@@ -1542,7 +1536,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
minHeight: 0,
}}
>
-
+
);
diff --git a/Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx b/Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx
index 1b8cdadb..56d01615 100644
--- a/Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx
+++ b/Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx
@@ -96,7 +96,6 @@ export default function ReverseTunnelPowershell({ device }) {
const localSocketRef = useRef(false);
const terminalRef = useRef(null);
const agentIdRef = useRef("");
- const tunnelIdRef = useRef("");
const agentId = useMemo(() => {
return (
@@ -115,9 +114,6 @@ export default function ReverseTunnelPowershell({ device }) {
agentIdRef.current = agentId;
}, [agentId]);
- useEffect(() => {
- tunnelIdRef.current = tunnel?.tunnel_id || "";
- }, [tunnel?.tunnel_id]);
const ensureSocket = useCallback(() => {
if (socketRef.current) return socketRef.current;
@@ -181,15 +177,14 @@ export default function ReverseTunnelPowershell({ device }) {
scrollToBottom();
}, [output, scrollToBottom]);
- const stopTunnel = useCallback(async (reason = "operator_disconnect") => {
+ const disconnectShell = useCallback(async (reason = "operator_disconnect") => {
const currentAgentId = agentIdRef.current;
if (!currentAgentId) return;
- const currentTunnelId = tunnelIdRef.current;
try {
- await fetch("/api/tunnel/disconnect", {
- method: "DELETE",
+ await fetch("/api/shell/disconnect", {
+ method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ agent_id: currentAgentId, tunnel_id: currentTunnelId, reason }),
+ body: JSON.stringify({ agent_id: currentAgentId, reason }),
});
} catch {
// best-effort
@@ -206,14 +201,14 @@ export default function ReverseTunnelPowershell({ device }) {
setStatusMessage("");
try {
await closeShell();
- await stopTunnel("operator_disconnect");
+ await disconnectShell("operator_disconnect");
} finally {
setTunnel(null);
setShellState("closed");
setSessionState("idle");
setLoading(false);
}
- }, [closeShell, stopTunnel]);
+ }, [closeShell, disconnectShell]);
useEffect(() => {
const socket = ensureSocket();
@@ -250,77 +245,39 @@ export default function ReverseTunnelPowershell({ device }) {
useEffect(() => {
return () => {
closeShell();
- stopTunnel("component_unmount");
+ disconnectShell("component_unmount");
};
- }, [closeShell, stopTunnel]);
+ }, [closeShell, disconnectShell]);
const requestTunnel = useCallback(async () => {
if (!agentId) {
- setStatusMessage("Agent ID is required to connect.");
+ setStatusMessage("Agent ID is required to establish.");
return;
}
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;
- }
- } catch {
- // best-effort readiness check
- }
-
setSessionState("connecting");
setShellState("opening");
- const resp = await fetch("/api/tunnel/connect", {
+ const resp = await fetch("/api/shell/establish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: agentId }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
+ if (data?.error === "agent_socket_missing") {
+ await handleAgentOnboarding();
+ return;
+ }
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) {
+ if (data?.agent_socket === false) {
+ await handleAgentOnboarding();
return;
}
- setTunnel({ ...data, ...statusData });
+ setTunnel(data);
const socket = ensureSocket();
const openShellWithRetry = async () => {
@@ -335,7 +292,6 @@ export default function ReverseTunnelPowershell({ device }) {
}
if (openResp.error === "agent_socket_missing") {
await handleAgentOnboarding();
- await stopTunnel("agent_onboarding_pending");
return null;
}
lastError = openResp.error;
@@ -359,7 +315,7 @@ export default function ReverseTunnelPowershell({ device }) {
} finally {
setLoading(false);
}
- }, [agentId, ensureSocket, handleAgentOnboarding, stopTunnel]);
+ }, [agentId, ensureSocket, handleAgentOnboarding]);
const handleSend = useCallback(
async (text) => {
@@ -414,7 +370,7 @@ export default function ReverseTunnelPowershell({ device }) {
disabled={loading || (!isConnected && !agentId)}
onClick={isConnected ? handleDisconnect : requestTunnel}
>
- {isConnected ? "Disconnect" : "Connect"}
+ {isConnected ? "Disconnect" : "Establish"}
{sessionChips.map((chip) => (
@@ -510,7 +466,7 @@ export default function ReverseTunnelPowershell({ device }) {
size="small"
value={input}
disabled={!isConnected}
- placeholder={isConnected ? "Enter PowerShell command and press Enter" : "Connect to start sending commands"}
+ placeholder={isConnected ? "Enter PowerShell command and press Enter" : "Establish to start sending commands"}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
diff --git a/Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx b/Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx
deleted file mode 100644
index 816f5c5a..00000000
--- a/Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx
+++ /dev/null
@@ -1,552 +0,0 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import {
- Box,
- Button,
- Chip,
- FormControl,
- InputLabel,
- MenuItem,
- Select,
- Stack,
- TextField,
- Typography,
- LinearProgress,
-} from "@mui/material";
-import {
- DesktopWindowsRounded as DesktopIcon,
- PlayArrowRounded as PlayIcon,
- StopRounded as StopIcon,
- LinkRounded as LinkIcon,
- LanRounded as IpIcon,
-} from "@mui/icons-material";
-import Guacamole from "guacamole-common-js";
-
-const MAGIC_UI = {
- panelBorder: "rgba(148, 163, 184, 0.35)",
- textMuted: "#94a3b8",
- textBright: "#e2e8f0",
- accentA: "#7dd3fc",
- accentB: "#c084fc",
- accentC: "#34d399",
-};
-
-const gradientButtonSx = {
- backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
- color: "#0b1220",
- borderRadius: 999,
- textTransform: "none",
- px: 2.2,
- minWidth: 120,
- "&:hover": {
- backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
- },
-};
-
-const PROTOCOLS = [{ value: "rdp", label: "RDP" }];
-
-const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
-
-function normalizeText(value) {
- if (value == null) return "";
- try {
- return String(value).trim();
- } catch {
- return "";
- }
-}
-
-export default function ReverseTunnelRdp({ device }) {
- const [sessionState, setSessionState] = useState("idle");
- const [statusMessage, setStatusMessage] = useState("");
- const [loading, setLoading] = useState(false);
- const [tunnel, setTunnel] = useState(null);
- const [protocol, setProtocol] = useState("rdp");
- const [username, setUsername] = useState("");
- const [password, setPassword] = useState("");
- const containerRef = useRef(null);
- const displayRef = useRef(null);
- const clientRef = useRef(null);
- const tunnelRef = useRef(null);
- const mouseRef = useRef(null);
- const agentIdRef = useRef("");
- const tunnelIdRef = useRef("");
- const bumpTimerRef = useRef(null);
-
- const agentId = useMemo(() => {
- return (
- normalizeText(device?.agent_id) ||
- normalizeText(device?.agentId) ||
- normalizeText(device?.agent_guid) ||
- normalizeText(device?.agentGuid) ||
- normalizeText(device?.id) ||
- normalizeText(device?.guid) ||
- normalizeText(device?.summary?.agent_id) ||
- ""
- );
- }, [device]);
-
- useEffect(() => {
- agentIdRef.current = agentId;
- }, [agentId]);
-
- useEffect(() => {
- tunnelIdRef.current = tunnel?.tunnel_id || "";
- }, [tunnel?.tunnel_id]);
-
- const notifyAgentOnboarding = useCallback(async () => {
- try {
- await fetch("/api/notifications/notify", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({
- title: "Agent Onboarding Underway",
- message:
- "Please wait for the agent to finish onboarding into Borealis. It takes about 1 minute to finish the process.",
- icon: "info",
- variant: "info",
- }),
- });
- } catch {
- /* ignore notification transport errors */
- }
- }, []);
-
- const handleAgentOnboarding = useCallback(async () => {
- await notifyAgentOnboarding();
- setStatusMessage("Agent Onboarding Underway.");
- setSessionState("idle");
- setTunnel(null);
- }, [notifyAgentOnboarding]);
-
- const teardownDisplay = useCallback(() => {
- try {
- const client = clientRef.current;
- if (client) {
- client.disconnect();
- }
- } catch {
- /* ignore */
- }
- clientRef.current = null;
- tunnelRef.current = null;
- mouseRef.current = null;
- const host = displayRef.current;
- if (host) {
- host.innerHTML = "";
- }
- }, []);
-
- const stopTunnel = useCallback(async (reason = "operator_disconnect") => {
- const currentAgentId = agentIdRef.current;
- if (!currentAgentId) return;
- const currentTunnelId = tunnelIdRef.current;
- try {
- await fetch("/api/tunnel/disconnect", {
- method: "DELETE",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ agent_id: currentAgentId, tunnel_id: currentTunnelId, reason }),
- });
- } catch {
- // best-effort
- }
- }, []);
-
- const clearBumpTimer = useCallback(() => {
- if (bumpTimerRef.current) {
- clearInterval(bumpTimerRef.current);
- bumpTimerRef.current = null;
- }
- }, []);
-
- const startBumpTimer = useCallback(
- (currentAgentId) => {
- clearBumpTimer();
- if (!currentAgentId) return;
- bumpTimerRef.current = setInterval(async () => {
- try {
- await fetch(`/api/tunnel/connect/status?agent_id=${encodeURIComponent(currentAgentId)}&bump=1`);
- } catch {
- /* ignore */
- }
- }, 60000);
- },
- [clearBumpTimer]
- );
-
- const handleDisconnect = useCallback(async () => {
- setLoading(true);
- setStatusMessage("");
- clearBumpTimer();
- try {
- teardownDisplay();
- await stopTunnel("operator_disconnect");
- } finally {
- setTunnel(null);
- setSessionState("idle");
- setLoading(false);
- }
- }, [clearBumpTimer, stopTunnel, teardownDisplay]);
-
- const scaleToFit = useCallback(() => {
- const client = clientRef.current;
- const container = containerRef.current;
- if (!client || !container) return;
- const display = client.getDisplay();
- const displayWidth = display.getWidth();
- const displayHeight = display.getHeight();
- const bounds = container.getBoundingClientRect();
- if (!displayWidth || !displayHeight || !bounds.width || !bounds.height) return;
- const scale = Math.min(bounds.width / displayWidth, bounds.height / displayHeight);
- display.scale(scale);
- }, []);
-
- useEffect(() => {
- const handleResize = () => scaleToFit();
- window.addEventListener("resize", handleResize);
- let observer = null;
- if (typeof ResizeObserver !== "undefined" && containerRef.current) {
- observer = new ResizeObserver(() => scaleToFit());
- observer.observe(containerRef.current);
- }
- return () => {
- window.removeEventListener("resize", handleResize);
- if (observer) observer.disconnect();
- };
- }, [scaleToFit]);
-
- useEffect(() => {
- return () => {
- clearBumpTimer();
- teardownDisplay();
- stopTunnel("component_unmount");
- };
- }, [clearBumpTimer, stopTunnel, teardownDisplay]);
-
- const requestTunnel = useCallback(async () => {
- if (!agentId) {
- setStatusMessage("Agent ID is required to connect.");
- return null;
- }
- setLoading(true);
- setStatusMessage("");
- try {
- try {
- const readinessResp = await fetch(`/api/tunnel/status?agent_id=${encodeURIComponent(agentId)}`);
- const readinessData = await readinessResp.json().catch(() => ({}));
- if (readinessResp.ok && readinessData?.agent_socket !== true) {
- await handleAgentOnboarding();
- return null;
- }
- } catch {
- // best-effort readiness check
- }
-
- setSessionState("connecting");
- const resp = await fetch("/api/tunnel/connect", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ agent_id: agentId }),
- });
- const data = await resp.json().catch(() => ({}));
- if (!resp.ok) {
- const detail = data?.detail ? `: ${data.detail}` : "";
- throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
- }
- tunnelIdRef.current = data?.tunnel_id || "";
-
- const waitForTunnelReady = async () => {
- const deadline = Date.now() + 60000;
- let lastError = "";
- while (Date.now() < deadline) {
- const statusResp = await fetch(
- `/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1`
- );
- const statusData = await statusResp.json().catch(() => ({}));
- if (statusData?.error === "agent_socket_missing" || (statusResp.ok && statusData?.agent_socket === false)) {
- await handleAgentOnboarding();
- await stopTunnel("agent_onboarding_pending");
- return null;
- }
- if (statusResp.ok && statusData?.status === "up") {
- const agentSocket = statusData?.agent_socket;
- const agentReady = agentSocket === undefined ? true : Boolean(agentSocket);
- if (agentReady) {
- return statusData;
- }
- setStatusMessage("Waiting for agent VPN socket to register...");
- } else if (statusData?.error) {
- lastError = statusData.error;
- }
- await sleep(2000);
- }
- throw new Error(lastError || "Tunnel not ready");
- };
-
- const statusData = await waitForTunnelReady();
- if (!statusData) {
- return null;
- }
- setTunnel({ ...data, ...statusData });
- startBumpTimer(agentId);
- return statusData;
- } catch (err) {
- setSessionState("error");
- setStatusMessage(String(err.message || err));
- return null;
- } finally {
- setLoading(false);
- }
- }, [agentId, handleAgentOnboarding, startBumpTimer, stopTunnel]);
-
- const openRdpSession = useCallback(
- async () => {
- const currentAgentId = agentIdRef.current;
- if (!currentAgentId) return;
- const payload = {
- agent_id: currentAgentId,
- protocol,
- username: username.trim(),
- password,
- };
- const resp = await fetch("/api/rdp/session", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- const data = await resp.json().catch(() => ({}));
- if (!resp.ok) {
- const detail = data?.detail ? `: ${data.detail}` : "";
- throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
- }
- const token = data?.token;
- const wsUrl = data?.ws_url;
- if (!token || !wsUrl) {
- throw new Error("RDP session unavailable.");
- }
- const tunnelUrl = `${wsUrl}?token=${encodeURIComponent(token)}`;
- const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
- const client = new Guacamole.Client(tunnel);
- const displayHost = displayRef.current;
-
- tunnel.onerror = (status) => {
- setStatusMessage(status?.message || "RDP tunnel error.");
- };
- client.onerror = (status) => {
- setStatusMessage(status?.message || "RDP client error.");
- };
- client.onstatechange = (state) => {
- if (state === Guacamole.Client.State.CONNECTED) {
- setSessionState("connected");
- setStatusMessage("");
- } else if (state === Guacamole.Client.State.DISCONNECTED) {
- setSessionState("idle");
- }
- };
- client.onresize = () => {
- scaleToFit();
- };
-
- if (displayHost) {
- displayHost.innerHTML = "";
- displayHost.appendChild(client.getDisplay().getElement());
- }
-
- const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
- mouse.onmousemove = (state) => client.sendMouseState(state);
- mouse.onmousedown = (state) => client.sendMouseState(state);
- mouse.onmouseup = (state) => client.sendMouseState(state);
-
- clientRef.current = client;
- tunnelRef.current = tunnel;
- mouseRef.current = mouse;
- client.connect();
- scaleToFit();
- setStatusMessage("Connecting to RDP...");
- },
- [password, protocol, scaleToFit, username]
- );
-
- const handleConnect = useCallback(async () => {
- if (sessionState === "connected") return;
- setStatusMessage("");
- setSessionState("connecting");
- try {
- const tunnelReady = await requestTunnel();
- if (!tunnelReady) {
- return;
- }
- await openRdpSession();
- } catch (err) {
- setSessionState("error");
- setStatusMessage(String(err.message || err));
- }
- }, [openRdpSession, requestTunnel, sessionState]);
-
- const isConnected = sessionState === "connected";
- const sessionChips = [
- tunnel?.tunnel_id
- ? {
- label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`,
- color: MAGIC_UI.accentB,
- icon: ,
- }
- : null,
- tunnel?.virtual_ip
- ? {
- label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
- color: MAGIC_UI.accentA,
- icon: ,
- }
- : null,
- ].filter(Boolean);
-
- return (
-
-
- : }
- sx={gradientButtonSx}
- disabled={loading || (!isConnected && !agentId)}
- onClick={isConnected ? handleDisconnect : handleConnect}
- >
- {isConnected ? "Disconnect" : "Connect"}
-
-
-
- Protocol
-
-
- setUsername(e.target.value)}
- disabled={isConnected}
- sx={{
- minWidth: 180,
- input: { color: MAGIC_UI.textBright },
- "& .MuiOutlinedInput-root": {
- backgroundColor: "rgba(12,18,35,0.9)",
- borderRadius: 2,
- "& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
- "&:hover fieldset": { borderColor: MAGIC_UI.accentA },
- },
- "& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
- }}
- />
- setPassword(e.target.value)}
- disabled={isConnected}
- sx={{
- minWidth: 180,
- input: { color: MAGIC_UI.textBright },
- "& .MuiOutlinedInput-root": {
- backgroundColor: "rgba(12,18,35,0.9)",
- borderRadius: 2,
- "& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
- "&:hover fieldset": { borderColor: MAGIC_UI.accentA },
- },
- "& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
- }}
- />
-
-
- {sessionChips.map((chip) => (
-
- ))}
-
-
-
-
- {loading ? : null}
-
-
- {!isConnected ? (
-
-
-
- Connect to start the remote desktop session.
-
-
- ) : null}
-
-
-
-
-
- Session: {isConnected ? "Active" : sessionState}
-
- {statusMessage ? (
-
- {statusMessage}
-
- ) : null}
-
-
- );
-}
diff --git a/Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx b/Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx
new file mode 100644
index 00000000..974246e4
--- /dev/null
+++ b/Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx
@@ -0,0 +1,351 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Box,
+ Button,
+ Chip,
+ Stack,
+ 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 RFB from "@novnc/novnc/lib/rfb";
+
+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)",
+ },
+};
+
+function normalizeText(value) {
+ if (value == null) return "";
+ try {
+ return String(value).trim();
+ } catch {
+ return "";
+ }
+}
+
+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 containerRef = useRef(null);
+ const displayRef = useRef(null);
+ const rfbRef = useRef(null);
+ const agentIdRef = useRef("");
+
+ 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]);
+
+
+ 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 = rfbRef.current;
+ if (client) {
+ client.disconnect();
+ }
+ } catch {
+ /* ignore */
+ }
+ rfbRef.current = null;
+ const host = displayRef.current;
+ if (host) {
+ host.innerHTML = "";
+ }
+ }, []);
+
+ const disconnectVnc = useCallback(async (reason = "operator_disconnect") => {
+ const currentAgentId = agentIdRef.current;
+ if (!currentAgentId) return;
+ try {
+ await fetch("/api/vnc/disconnect", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ agent_id: currentAgentId, reason }),
+ });
+ } catch {
+ // best-effort
+ }
+ }, []);
+
+ const handleDisconnect = useCallback(async () => {
+ setLoading(true);
+ setStatusMessage("");
+ try {
+ teardownDisplay();
+ await disconnectVnc("operator_disconnect");
+ } finally {
+ setTunnel(null);
+ setSessionState("idle");
+ setLoading(false);
+ }
+ }, [disconnectVnc, teardownDisplay]);
+
+ useEffect(() => {
+ return () => {
+ teardownDisplay();
+ disconnectVnc("component_unmount");
+ };
+ }, [disconnectVnc, teardownDisplay]);
+
+ const requestTunnel = useCallback(async () => {
+ if (!agentId) {
+ setStatusMessage("Agent ID is required to establish.");
+ return null;
+ }
+ setLoading(true);
+ setStatusMessage("");
+ try {
+ setSessionState("connecting");
+ const resp = await fetch("/api/vnc/establish", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ agent_id: agentId }),
+ });
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) {
+ if (data?.error === "agent_socket_missing") {
+ await handleAgentOnboarding();
+ return null;
+ }
+ const detail = data?.detail ? `: ${data.detail}` : "";
+ throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
+ }
+ setTunnel({
+ tunnel_id: data?.tunnel_id,
+ virtual_ip: data?.virtual_ip,
+ });
+ return data;
+ } catch (err) {
+ setSessionState("error");
+ setStatusMessage(String(err.message || err));
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [agentId, handleAgentOnboarding]);
+
+ const openVncSession = useCallback(async (data) => {
+ const token = data?.token;
+ const wsUrl = data?.ws_url;
+ const vncPassword = data?.vnc_password || "";
+ if (!token || !wsUrl) {
+ throw new Error("VNC session unavailable.");
+ }
+ const tunnelUrl = `${wsUrl}?token=${encodeURIComponent(token)}`;
+ const displayHost = displayRef.current;
+ if (!displayHost) {
+ throw new Error("VNC display container missing.");
+ }
+ displayHost.innerHTML = "";
+
+ const rfb = new RFB(displayHost, tunnelUrl, {
+ credentials: { password: vncPassword },
+ });
+ rfb.scaleViewport = true;
+ rfb.resizeSession = true;
+ rfb.clipViewport = true;
+
+ rfb.addEventListener("connect", () => {
+ setSessionState("connected");
+ setStatusMessage("");
+ });
+ rfb.addEventListener("disconnect", () => {
+ setSessionState("idle");
+ rfbRef.current = null;
+ });
+ rfb.addEventListener("securityfailure", (evt) => {
+ const detail = evt?.detail?.reason ? ` (${evt.detail.reason})` : "";
+ setSessionState("error");
+ setStatusMessage(`VNC authentication failed${detail}.`);
+ });
+ rfb.addEventListener("credentialsrequired", () => {
+ try {
+ rfb.sendCredentials({ password: vncPassword });
+ } catch {
+ // ignore
+ }
+ });
+
+ rfbRef.current = rfb;
+ setStatusMessage("Establishing VNC...");
+ }, []);
+
+ const handleConnect = useCallback(async () => {
+ if (sessionState === "connected") return;
+ setStatusMessage("");
+ setSessionState("connecting");
+ try {
+ const sessionData = await requestTunnel();
+ if (!sessionData) {
+ return;
+ }
+ await openVncSession(sessionData);
+ } catch (err) {
+ setSessionState("error");
+ setStatusMessage(String(err.message || err));
+ }
+ }, [openVncSession, requestTunnel, sessionState]);
+
+ const isConnected = sessionState === "connected";
+ const sessionChips = [
+ tunnel?.tunnel_id
+ ? {
+ label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`,
+ color: MAGIC_UI.accentB,
+ icon: ,
+ }
+ : null,
+ tunnel?.virtual_ip
+ ? {
+ label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
+ color: MAGIC_UI.accentA,
+ icon: ,
+ }
+ : null,
+ ].filter(Boolean);
+
+ return (
+
+
+ : }
+ sx={gradientButtonSx}
+ disabled={loading || (!isConnected && !agentId)}
+ onClick={isConnected ? handleDisconnect : handleConnect}
+ >
+ {isConnected ? "Disconnect" : "Establish"}
+
+
+ {sessionChips.map((chip) => (
+
+ ))}
+
+
+
+
+ {loading ? : null}
+
+
+ {!isConnected ? (
+
+
+
+ Establish to start the VNC session.
+
+
+ ) : null}
+
+
+
+
+
+ Session: {isConnected ? "Active" : sessionState}
+
+ {statusMessage ? (
+
+ {statusMessage}
+
+ ) : null}
+
+
+ );
+}
diff --git a/Data/Engine/web-interface/vite.config.mts b/Data/Engine/web-interface/vite.config.mts
index 09be004c..8bb0a214 100644
--- a/Data/Engine/web-interface/vite.config.mts
+++ b/Data/Engine/web-interface/vite.config.mts
@@ -42,6 +42,14 @@ const httpsOptions = certPath && keyPath
export default defineConfig({
plugins: [react()],
+ esbuild: {
+ target: "es2022",
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ target: "es2022",
+ },
+ },
server: {
open: true,
host: true,
@@ -69,6 +77,7 @@ export default defineConfig({
outDir: 'build',
emptyOutDir: true,
chunkSizeWarningLimit: 1000,
+ target: 'es2022',
rollupOptions: {
output: {
// split each npm package into its own chunk
diff --git a/Docs/agent-runtime.md b/Docs/agent-runtime.md
index f2dbdc1a..1bf0bc35 100644
--- a/Docs/agent-runtime.md
+++ b/Docs/agent-runtime.md
@@ -15,7 +15,7 @@ Describe the Borealis agent runtime, its roles, service modes, and how it commun
- `role_DeviceAudit.py` (ROLE_NAME: `device_audit`) - inventory and audit data capture.
- `role_Macro.py` (ROLE_NAME: `macro`) - macro automation.
- `role_PlaybookExec_SYSTEM.py` (ROLE_NAME: `playbook_exec_system`) - Ansible playbook runner (unfinished).
-- `role_RDP.py` (ROLE_NAME: `RDP`) - RDP readiness hooks.
+- `role_VNC.py` (ROLE_NAME: `VNC`) - on-demand UltraVNC server lifecycle.
- `role_RemotePowershell.py` (ROLE_NAME: `RemotePowershell`) - TCP PowerShell server over WireGuard.
- `role_Screenshot.py` (ROLE_NAME: `screenshot`) - screenshot capture.
- `role_ScriptExec_CURRENTUSER.py` (ROLE_NAME: `script_exec_currentuser`) - interactive PowerShell execution.
@@ -34,6 +34,7 @@ Describe the Borealis agent runtime, its roles, service modes, and how it commun
- `POST /api/agent/heartbeat` (Device Authenticated) - heartbeat + metrics.
- `POST /api/agent/details` (Device Authenticated) - hardware/inventory payloads.
- `POST /api/agent/script/request` (Device Authenticated) - request work or receive idle signal.
+- `POST /api/agent/vpn/ensure` (Device Authenticated) - persistent WireGuard tunnel bootstrap.
## Related Documentation
- [Security and Trust](security-and-trust.md)
@@ -66,8 +67,10 @@ Describe the Borealis agent runtime, its roles, service modes, and how it commun
- `AgentHttpClient.ensure_authenticated()` handles enrollment and refresh.
- Socket.IO is used for:
- `quick_job_run` dispatch (script execution payloads).
- - `vpn_tunnel_start` and `vpn_tunnel_stop` (WireGuard lifecycle).
+ - `vpn_tunnel_start` (WireGuard lifecycle; tunnels are persistent and ignore stop events).
- `connect_agent` registration (agent socket registry).
+- WireGuard tunnels are ensured via `POST /api/agent/vpn/ensure` on boot and refreshed periodically.
+- The ensure loop re-establishes the tunnel automatically after network hiccups.
### Token storage
- Refresh tokens are stored encrypted (DPAPI on Windows) in `refresh.token`.
@@ -89,7 +92,8 @@ Describe the Borealis agent runtime, its roles, service modes, and how it commun
- Confirm `quick_job_run` events and the correct role context.
- Verify signatures with `signature_utils` logs.
- If VPN fails:
- - Check agent WireGuard role logs and ensure the Engine emitted `vpn_tunnel_start`.
+ - Check agent WireGuard role logs and confirm `/api/agent/vpn/ensure` succeeds.
+ - Ensure the Engine has an active tunnel session and the WireGuard service is running.
### Borealis Agent Codex (Full)
Use this section for agent-only work (Borealis agent runtime under `Data/Agent` -> `/Agent`). Shared guidance is consolidated in `ui-and-notifications.md` and the Engine runtime notes.
diff --git a/Docs/api-reference.md b/Docs/api-reference.md
index daf08622..386ce896 100644
--- a/Docs/api-reference.md
+++ b/Docs/api-reference.md
@@ -32,6 +32,7 @@ Provide a consolidated, human-readable list of Borealis Engine API endpoints gro
- `POST /api/agent/heartbeat` (Device Authenticated) - heartbeat + metrics.
- `POST /api/agent/details` (Device Authenticated) - full hardware/inventory payload.
- `POST /api/agent/script/request` (Device Authenticated) - request work or idle signal.
+- `POST /api/agent/vpn/ensure` (Device Authenticated) - persistent WireGuard tunnel bootstrap.
- `GET /api/agents` (Token Authenticated) - list online collectors by hostname/context.
- `GET /api/devices` (Token Authenticated) - device summary list.
- `GET /api/devices/` (Token Authenticated) - device summary by GUID.
@@ -102,14 +103,18 @@ Provide a consolidated, human-readable list of Borealis Engine API endpoints gro
- `POST /api/notifications/notify` (Token Authenticated) - broadcast toast notification.
### VPN and Remote Access
-- `POST /api/tunnel/connect` (Token Authenticated) - start WireGuard tunnel.
+- `POST /api/tunnel/connect` (Token Authenticated) - ensure WireGuard tunnel material for an agent.
- `GET /api/tunnel/status` (Token Authenticated) - tunnel status by agent.
-- `GET /api/tunnel/connect/status` (Token Authenticated) - alias for status.
- `GET /api/tunnel/active` (Token Authenticated) - list active tunnels.
-- `DELETE /api/tunnel/disconnect` (Token Authenticated) - stop tunnel.
-### RDP
-- `POST /api/rdp/session` (Token Authenticated) - issue Guacamole RDP session token.
+### VNC
+- `POST /api/vnc/establish` (Token Authenticated) - establish VNC session token.
+- `POST /api/vnc/disconnect` (Token Authenticated) - disconnect VNC session.
+- `POST /api/vnc/session` (Token Authenticated) - legacy alias for establish.
+
+### Remote Shell
+- `POST /api/shell/establish` (Token Authenticated) - establish remote shell session.
+- `POST /api/shell/disconnect` (Token Authenticated) - disconnect remote shell session.
### Server Info and Logs
- `GET /api/server/time` (Operator Session) - server clock.
diff --git a/Docs/architecture-overview.md b/Docs/architecture-overview.md
index 9677d5c1..d6830756 100644
--- a/Docs/architecture-overview.md
+++ b/Docs/architecture-overview.md
@@ -10,16 +10,16 @@ Explain how Borealis is structured and how the core components interact end to e
- Agent: Python runtime that enrolls, reports inventory, executes scripts, and opens VPN tunnels.
- SQLite database: stores devices, approvals, schedules, activity history, tokens, and configuration records.
- Assemblies: script definitions stored in SQLite domains with payload artifacts on disk.
-- Remote access: WireGuard reverse VPN, remote PowerShell, and Guacamole-backed RDP proxy.
+- Remote access: WireGuard reverse VPN, remote PowerShell, and VNC via noVNC.
## How the Pieces Talk
- Enrollment: agent calls `/api/agent/enroll/request` and `/api/agent/enroll/poll`, operator approves, Engine issues tokens and cert bundle.
- Inventory: agent posts `/api/agent/heartbeat` and `/api/agent/details`, Engine updates device records.
- Quick jobs: operator calls `/api/scripts/quick_run`, Engine emits `quick_job_run` over Socket.IO, agent executes and returns `quick_job_result`.
- Scheduled jobs: scheduler reads jobs from DB, resolves targets (including filters), then emits quick jobs.
-- VPN tunnels: operator calls `/api/tunnel/connect`, Engine emits `vpn_tunnel_start`, agent starts WireGuard client.
+- VPN tunnels: agent calls `/api/agent/vpn/ensure`, Engine emits `vpn_tunnel_start`, agent keeps WireGuard client online.
- Remote shell: UI uses Socket.IO `vpn_shell_*` events, Engine bridges to agent TCP shell over WireGuard.
-- RDP: operator calls `/api/rdp/session`, Engine creates a one-time token and proxies Guacamole WebSocket to guacd.
+- VNC: operator calls `/api/vnc/establish`, Engine creates a one-time token and proxies noVNC WebSocket to the agent VNC server.
- Notifications: operator or services call `/api/notifications/notify`, WebUI receives `borealis_notification` events.
## Directory Map (High Level)
@@ -50,7 +50,7 @@ None on this page. See [API Reference](api-reference.md).
- Engine realtime: `Data/Engine/services/WebSocket/` (Socket.IO events: quick jobs, VPN shell, agent socket registry).
- WebUI hosting: `Data/Engine/services/WebUI/` (SPA static assets and 404 fallback).
- VPN orchestration: `Data/Engine/services/VPN/` (WireGuard server and tunnel lifecycle).
-- Remote desktop proxy: `Data/Engine/services/RemoteDesktop/` (Guacamole WebSocket proxy).
+- Remote desktop proxy: `Data/Engine/services/RemoteDesktop/` (VNC WebSocket proxy).
- Filters and targeting: `Data/Engine/services/filters/matcher.py` (used by scheduled jobs and filter counts).
- Agent roles: `Data/Agent/Roles/` (script exec, screenshot, WireGuard tunnel, remote PowerShell, etc).
@@ -61,11 +61,10 @@ None on this page. See [API Reference](api-reference.md).
3) Agent role executes and posts `quick_job_result` over Socket.IO.
4) Engine updates `activity_history` and emits `device_activity_changed`.
- VPN shell:
- 1) UI calls `/api/tunnel/connect` to request tunnel material.
- 2) Engine emits `vpn_tunnel_start` to agent socket.
- 3) Agent WireGuard role starts tunnel; agent shell role listens on TCP 47002.
- 4) UI opens `vpn_shell_open` Socket.IO event; Engine bridges to TCP shell.
- 5) UI sends/receives `vpn_shell_send` and `vpn_shell_output` events.
+ 1) UI calls `/api/shell/establish` to ensure shell readiness.
+ 2) Agent WireGuard role keeps the tunnel online; agent shell role listens on TCP 47002.
+ 3) UI opens `vpn_shell_open` Socket.IO event; Engine bridges to TCP shell.
+ 4) UI sends/receives `vpn_shell_send` and `vpn_shell_output` events.
### Runtime boundaries
- Do not edit `Engine/` or `Agent/` directly. They are recreated on each launch.
@@ -79,4 +78,4 @@ None on this page. See [API Reference](api-reference.md).
### Interaction points to remember
- REST for inventory, enrollment, and admin actions.
- Socket.IO for realtime job results, VPN shell, and notifications.
-- WireGuard for remote protocol transport (shell, RDP, future protocols).
+- WireGuard for remote protocol transport (shell, VNC, future protocols).
diff --git a/Docs/device-management.md b/Docs/device-management.md
index b0f60ed9..8f4364a3 100644
--- a/Docs/device-management.md
+++ b/Docs/device-management.md
@@ -73,7 +73,7 @@ Explain how Borealis tracks devices, ingests inventory, manages sites and filter
## Codex Agent (Detailed)
### Key files and services
-- Device APIs: `Data/Engine/services/API/devices/` (management, approval, tunnel, rdp, routes).
+- Device APIs: `Data/Engine/services/API/devices/` (management, approval, tunnel, vnc, routes).
- Filters: `Data/Engine/services/filters/matcher.py` and `Data/Engine/services/API/filters/management.py`.
- Enrollment approvals: `Data/Engine/services/API/devices/approval.py`.
diff --git a/Docs/engine-runtime.md b/Docs/engine-runtime.md
index 760aecbb..7842febc 100644
--- a/Docs/engine-runtime.md
+++ b/Docs/engine-runtime.md
@@ -11,7 +11,7 @@ Describe the Borealis Engine runtime, its services, configuration, and operation
- WebUI serving: `Data/Engine/services/WebUI/` (SPA static assets and 404 fallback).
- Realtime events: `Data/Engine/services/WebSocket/` (quick job results, VPN shell bridge).
- VPN orchestration: `Data/Engine/services/VPN/` (WireGuard server manager + tunnel service).
-- Remote desktop proxy: `Data/Engine/services/RemoteDesktop/` (Guacamole WebSocket bridge).
+- Remote desktop proxy: `Data/Engine/services/RemoteDesktop/` (VNC WebSocket bridge).
- Assemblies: `Data/Engine/assembly_management/` and `Data/Engine/services/assemblies/`.
## Runtime Paths
@@ -41,7 +41,7 @@ Describe the Borealis Engine runtime, its services, configuration, and operation
### EngineContext and lifecycle
- `Data/Engine/server.py` builds an `EngineContext` that includes:
- TLS paths, WireGuard settings, scheduler, Socket.IO instance.
- - RDP proxy settings (guacd host/port, ws host/port, session TTL).
+ - VNC proxy settings (VNC port, ws host/port, session TTL).
- The app factory wires in:
- API registration: `API.register_api(app, context)`
- WebUI static hosting: `WebUI.register_web_ui(app, context)`
@@ -72,11 +72,12 @@ Describe the Borealis Engine runtime, its services, configuration, and operation
- Dev UI uses Vite and still relies on Engine APIs for data.
- The SPA fallback in `Data/Engine/services/WebUI/__init__.py` prevents 404s on client routes.
-### WireGuard and RDP wiring
+### WireGuard and VNC wiring
- WireGuard server manager: `Data/Engine/services/VPN/wireguard_server.py`.
- Tunnel orchestration: `Data/Engine/services/VPN/vpn_tunnel_service.py`.
-- RDP proxy: `Data/Engine/services/RemoteDesktop/guacamole_proxy.py`.
-- API entrypoints: `/api/tunnel/*` and `/api/rdp/session`.
+- VNC proxy: `Data/Engine/services/RemoteDesktop/vnc_proxy.py`.
+- API entrypoints: `/api/vnc/establish`, `/api/vnc/disconnect`, `/api/shell/establish`, `/api/shell/disconnect`.
+- Persistent tunnels are established by agents via `POST /api/agent/vpn/ensure` and kept online.
### Assembly runtime
- Assembly cache is initialized in `Data/Engine/assembly_management` and attached to `context.assembly_cache`.
diff --git a/Docs/index.md b/Docs/index.md
index e5aeb25e..6a415496 100644
--- a/Docs/index.md
+++ b/Docs/index.md
@@ -49,7 +49,7 @@ None. This index only links to other pages.
- Read `getting-started.md` and `architecture-overview.md` to build the global model.
- Use `engine-runtime.md` and `agent-runtime.md` for implementation-level details.
- Use `ui-and-notifications.md` for MagicUI, AG Grid, and toast notification rules.
-- Use `vpn-and-remote-access.md` for WireGuard and remote shell/RDP details.
+- Use `vpn-and-remote-access.md` for WireGuard and remote shell/VNC details.
- Use `security-and-trust.md` for enrollment, tokens, and code-signing behavior.
### Where the truth lives in code
diff --git a/Docs/security-and-trust.md b/Docs/security-and-trust.md
index f0d02b39..d688403c 100644
--- a/Docs/security-and-trust.md
+++ b/Docs/security-and-trust.md
@@ -39,7 +39,7 @@ Explain the Borealis trust model, enrollment security, token handling, and code
### WireGuard Agent to Engine Tunnels
- Borealis started with a bespoke reverse tunnel stack (WebSocket framing + domain lanes); its handshake and security model did not scale, so the project moved to WireGuard as the Engine <-> Agent data pipeline for secure remote protocols and future remote desktop control.
-- On-demand, outbound-only: operators trigger a tunnel start, the agent dials the Engine (no inbound listeners), and the tunnel tears down on stop or idle.
+- Persistent, outbound-only: agents ensure the tunnel at boot (no inbound listeners), and it remains online while the agent runs.
- Shared sessions: one live VPN tunnel per agent, reused across operators to avoid redundant connections.
- Fast and robust transport: WireGuard provides encrypted UDP transport with lightweight handshakes that keep latency low and reconnects resilient.
- Orchestration security: the Engine issues short-lived, Ed25519-signed tunnel tokens that the agent verifies before bringing the tunnel up.
@@ -47,8 +47,8 @@ Explain the Borealis trust model, enrollment security, token handling, and code
- Isolation by default: each agent gets a host-only /32; AllowedIPs are restricted to the agent /32 and the Engine /32; no LAN routes and no client-to-client traffic.
- Port-level controls: per-device allowlists plus Engine-applied firewall rules limit which protocols can traverse the tunnel.
- Live PowerShell today: a VPN-only shell endpoint enables remote command execution with SYSTEM-level (`NT AUTHORITY\\SYSTEM`) access for deep diagnostics and remediation.
-- Session lifecycle: 15-minute idle timeout with no grace period; session material includes a virtual IP plus allowed ports; teardown removes the tunnel and firewall rules.
-- Future protocols: extend the same tunnel for SSH, WinRM, RDP, VNC, WebRTC streaming, and other remote management workflows by enabling ports per device.
+- Session lifecycle: tunnels stay online with `PersistentKeepalive = 30`; session material includes a virtual IP plus allowed ports; role-level disconnects (shell/VNC) leave the tunnel intact.
+- Future protocols: extend the same tunnel for SSH, WinRM, VNC, WebRTC streaming, and other remote management workflows by enabling ports per device.
## Enrollment and Identity
- Enrollment uses install codes and operator approval.
diff --git a/Docs/ui-and-notifications.md b/Docs/ui-and-notifications.md
index 452d6e6d..45155642 100644
--- a/Docs/ui-and-notifications.md
+++ b/Docs/ui-and-notifications.md
@@ -270,7 +270,7 @@ This section captures the UI behavior requirements and troubleshooting context f
#### Important references
- Toast API and payload rules are documented above.
- UI file: `Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx`.
-- API status endpoint: `/api/tunnel/status` returns `agent_socket` when available.
+- API establish endpoint: `/api/shell/establish` returns `agent_socket` when available.
- Socket error path: `agent_socket_missing`.
#### Troubleshooting context
diff --git a/Docs/vpn-and-remote-access.md b/Docs/vpn-and-remote-access.md
index 611de539..33ec7006 100644
--- a/Docs/vpn-and-remote-access.md
+++ b/Docs/vpn-and-remote-access.md
@@ -2,25 +2,28 @@
[Back to Docs Index](index.md) | [Index (HTML)](index.html)
## Purpose
-Document Borealis remote access features: WireGuard reverse VPN tunnels, remote PowerShell, and RDP via Guacamole.
+Document Borealis remote access features: WireGuard reverse VPN tunnels, remote PowerShell, and VNC via noVNC.
## WireGuard Reverse VPN (High Level)
- Outbound-only: agents initiate tunnels to the Engine; no inbound listeners on devices.
- Transport: WireGuard UDP 30000.
-- One active tunnel per agent, shared across operators.
+- One persistent tunnel per agent, established at agent boot and shared across operators.
- Host-only routing: each agent gets a /32; no client-to-client routes.
-- Idle timeout: 15 minutes without activity.
+- Keepalive: `PersistentKeepalive = 30` seconds on the agent.
+- No idle teardown while the agent service is running.
- Per-device allowlist: ports are restricted per device and enforced by Engine firewall rules.
## Remote PowerShell
- Uses the WireGuard tunnel and a TCP shell server on the agent.
+- UI establishes sessions via `/api/shell/establish` and disconnects via `/api/shell/disconnect`.
- Engine bridges UI Socket.IO events to the agent TCP shell.
- Shell port default: 47002 (configurable).
-## RDP via Guacamole
-- Engine issues one-time RDP session tokens via `/api/rdp/session`.
-- WebUI connects to `ws(s)://:4823/guacamole`.
-- RDP allowed only if the device allowlist includes 3389.
+## VNC via noVNC
+- Engine issues one-time VNC session tokens via `/api/vnc/establish`.
+- WebUI connects to `ws(s)://:4823/vnc`.
+- VNC allowed only if the device allowlist includes 5900.
+- Agent runs UltraVNC as a Windows service; Borealis opens the VNC firewall rule on demand and closes it via `/api/vnc/disconnect`.
## Reverse Proxy Configuration
Traefik dynamic config (replace service URL with the actual Borealis Engine URL):
@@ -62,14 +65,16 @@ http:
```
## API Endpoints
-- `POST /api/tunnel/connect` (Token Authenticated) - start WireGuard tunnel.
+- `POST /api/tunnel/connect` (Token Authenticated) - ensure WireGuard tunnel material for an agent.
- `GET /api/tunnel/status` (Token Authenticated) - tunnel status by agent.
-- `GET /api/tunnel/connect/status` (Token Authenticated) - alias for status.
- `GET /api/tunnel/active` (Token Authenticated) - list active tunnels.
-- `DELETE /api/tunnel/disconnect` (Token Authenticated) - stop tunnel.
+- `POST /api/agent/vpn/ensure` (Device Authenticated) - agent-side persistent tunnel bootstrap.
- `GET /api/device/vpn_config/` (Token Authenticated) - read allowed ports.
- `PUT /api/device/vpn_config/` (Token Authenticated) - update allowed ports.
-- `POST /api/rdp/session` (Token Authenticated) - issue RDP session token.
+- `POST /api/vnc/establish` (Token Authenticated) - establish VNC session token.
+- `POST /api/vnc/disconnect` (Token Authenticated) - disconnect VNC session (closes firewall).
+- `POST /api/shell/establish` (Token Authenticated) - establish remote shell session.
+- `POST /api/shell/disconnect` (Token Authenticated) - disconnect remote shell session.
## Related Documentation
- [Device Management](device-management.md)
@@ -83,12 +88,13 @@ http:
- WireGuard server manager: `Data/Engine/services/VPN/wireguard_server.py`.
- Tunnel API: `Data/Engine/services/API/devices/tunnel.py`.
- Shell bridge: `Data/Engine/services/WebSocket/vpn_shell.py`.
-- RDP session API: `Data/Engine/services/API/devices/rdp.py`.
-- Guacamole proxy: `Data/Engine/services/RemoteDesktop/guacamole_proxy.py`.
+- VNC session API: `Data/Engine/services/API/devices/vnc.py`.
+- VNC proxy: `Data/Engine/services/RemoteDesktop/vnc_proxy.py`.
### Core Agent files
- WireGuard client role: `Data/Agent/Roles/role_WireGuardTunnel.py`.
- Remote PowerShell role: `Data/Agent/Roles/role_RemotePowershell.py`.
+- VNC role: `Data/Agent/Roles/role_VNC.py`.
### Config paths
- Engine WireGuard config: `Engine/WireGuard/borealis-wg.conf`.
@@ -105,11 +111,11 @@ http:
- `Borealis - WireGuard - Agent`
### Event flow (WireGuard tunnel)
-1) UI calls `/api/tunnel/connect`.
-2) Engine creates a tunnel session and emits `vpn_tunnel_start`.
-3) Agent verifies token signature and starts WireGuard client.
+1) Agent calls `/api/agent/vpn/ensure` at boot (and periodically) to establish the persistent tunnel.
+2) Engine creates or reuses the tunnel session and emits `vpn_tunnel_start`.
+3) Agent verifies the token signature and starts the WireGuard client with `PersistentKeepalive = 30`.
4) Engine applies firewall allowlist rules for the agent /32.
-5) Activity is recorded in `activity_history` as a VPN event.
+5) UI sessions (shell/VNC) reuse the existing tunnel without teardown.
### Event flow (Remote PowerShell)
1) UI opens a shell and emits `vpn_shell_open`.
@@ -119,13 +125,16 @@ http:
5) Agent returns stdout frames; Engine emits `vpn_shell_output`.
### Allowed ports and ACL rules
-- Default allowlist (Windows): 3389, 5985, 5986, 5900, 3478, 47002.
+- Default allowlist (Windows): 5985, 5986, 5900, 3478, 47002.
- Per-device overrides are stored in `device_vpn_config`.
- Engine creates outbound firewall rules for each allowed port and protocol.
-### Idle timeout behavior
-- The tunnel idle timer resets when activity is detected or when `bump_activity` is called.
-- Idle sessions are torn down and firewall rules are removed.
+### Persistent tunnel behavior
+- WireGuard sessions stay online while the agent service is running.
+- There are no tunnel-disconnect endpoints; only role-level disconnects (shell/VNC) exist.
+- The tunnel and per-device firewall rules remain in place while the agent runs.
+- Keepalive is handled by WireGuard (`PersistentKeepalive = 30` seconds).
+- The agent periodically calls `/api/agent/vpn/ensure` to heal the tunnel if it drops.
### Logs to inspect
- Engine tunnel log: `Engine/Logs/VPN_Tunnel/tunnel.log`.
@@ -135,27 +144,28 @@ http:
### Troubleshooting checklist
- Confirm WireGuard service is running (Engine and Agent).
+- Confirm the agent successfully calls `/api/agent/vpn/ensure` after boot.
- Confirm `/api/tunnel/status` returns `status=up` and `agent_socket=true`.
- Verify `Agent/Borealis/Settings/WireGuard/Borealis.conf` during an active session.
- Test TCP shell reachability: `Test-NetConnection -Port 47002`.
### Known limitations
- Legacy WebSocket tunnels are retired; only WireGuard is supported.
-- RDP requires guacd running and port 3389 allowed in VPN config.
+- VNC requires UltraVNC running on the agent and port 5900 allowed in VPN config.
### Reverse VPN Tunnels (WireGuard) - Full Reference
#### 1) High-level model
- Outbound-only: agents establish WireGuard tunnels to the Engine; no inbound access on devices.
- Transport: WireGuard/UDP on port 30000.
-- Sessions: one live VPN tunnel per agent; multiple operators share it.
+- Sessions: one persistent VPN tunnel per agent; multiple operators share it.
- Routing: host-only /32 per agent; AllowedIPs restricted to the agent /32 and engine /32; no client-to-client.
-- Idle timeout: 15 minutes of no operator activity; no grace period.
+- Keepalive: `PersistentKeepalive = 30` seconds; tunnels stay up while agents run.
- Keys: WireGuard server keys under `Engine/Certificates/VPN_Server`; client keys under `Agent/Borealis/Certificates/VPN_Client`.
#### 2) Engine components
- Orchestrator: `Data/Engine/services/VPN/vpn_tunnel_service.py`
- Allocates per-agent /32, issues short-lived orchestration tokens, enforces single-session.
- - Starts/stops WireGuard listener, applies firewall rules, idles out on inactivity.
+ - Keeps the WireGuard listener online, applies firewall rules, and avoids idle teardown in persistent mode.
- Emits Socket.IO events: `vpn_tunnel_start`, `vpn_tunnel_stop`, `vpn_tunnel_activity`.
- WireGuard manager: `Data/Engine/services/VPN/wireguard_server.py`
- Generates server keys, renders config, manages `wireguard.exe` tunnel service, applies ACL rules.
@@ -164,17 +174,20 @@ http:
- Logging: `Engine/Logs/VPN_Tunnel/tunnel.log` plus Device Activity entries; shell I/O is in `Engine/Logs/VPN_Tunnel/remote_shell.log`.
#### 3) API endpoints
-- `POST /api/tunnel/connect` -> issues session material (tunnel_id, token, virtual_ip, endpoint, allowed_ports, idle_seconds).
+- `POST /api/tunnel/connect` -> issues or reuses session material (tunnel_id, token, virtual_ip, endpoint, allowed_ports).
- `GET /api/tunnel/status` -> returns up/down status for an agent.
-- `GET /api/tunnel/connect/status` -> alias for status (used by UI before shell open).
- `GET /api/tunnel/active` -> lists active VPN tunnel sessions (tunnel_id, agent_id, virtual_ip, last_activity, etc.).
-- `DELETE /api/tunnel/disconnect` -> immediate teardown (agent and engine cleanup).
+- `POST /api/agent/vpn/ensure` -> device-authenticated tunnel bootstrap for persistent mode.
+- `POST /api/shell/establish` -> establish remote shell session.
+- `POST /api/shell/disconnect` -> disconnect remote shell session.
+- `POST /api/vnc/establish` -> establish VNC session token.
+- `POST /api/vnc/disconnect` -> disconnect VNC session (closes firewall).
- `GET /api/device/vpn_config/` -> read per-agent allowed ports.
- `PUT /api/device/vpn_config/` -> update allowed ports.
#### 4) Agent components
- Tunnel lifecycle: `Data/Agent/Roles/role_WireGuardTunnel.py`
- - Validates orchestration tokens, starts/stops WireGuard client service, enforces idle.
+ - Validates orchestration tokens, starts WireGuard client service, keeps the tunnel persistent.
- Shell server: `Data/Agent/Roles/role_RemotePowershell.py`
- TCP PowerShell server bound to `0.0.0.0:47002`, restricted to VPN subnet (10.255.x.x).
- Logging: `Agent/Logs/VPN_Tunnel/tunnel.log` (tunnel lifecycle) and `Agent/Logs/VPN_Tunnel/remote_shell.log` (shell I/O).
@@ -221,8 +234,8 @@ This section consolidates the troubleshooting context and environment notes for
- Configs must live inside the project root:
- Agent: Agent\Borealis\Settings\WireGuard\Borealis.conf
- Engine: Engine\WireGuard\borealis-wg.conf
-- Agent brings up the WireGuard tunnel on vpn_tunnel_start, then remote shell/RDP/VNC/SSH flow through it.
-- On stop/idle, the tunnel is torn down and firewall rules removed.
+- Agent ensures the WireGuard tunnel on boot via `/api/agent/vpn/ensure`, then remote shell/VNC/SSH flow through it.
+- No idle teardown; tunnels and firewall rules stay in place while the agent is running.
#### Recent changes (current repo state)
- Data/Agent/Roles/role_WireGuardTunnel.py
@@ -231,7 +244,8 @@ This section consolidates the troubleshooting context and environment notes for
- Endpoint override: if Engine sends localhost, use host from server_url.txt and port from the token.
- Config path preference: Agent\Borealis\Settings\WireGuard.
- Service display name set to "Borealis - WireGuard - Agent".
- - Applies/removes the VPN shell firewall rule using the engine /32 from allowed_ips.
+ - Persistent tunnels with `PersistentKeepalive = 30`.
+ - Applies the VPN shell firewall rule using the engine /32 from allowed_ips.
- Data/Engine/services/VPN/wireguard_server.py
- Engine config path: Engine\WireGuard\borealis-wg.conf (project root only).
- Removed invalid "SaveConfig = false" line (WireGuard rejected it).
@@ -283,5 +297,5 @@ Note: Data/Agent changes only apply after Borealis.ps1 re-stages the agent under
#### Current blockers and next steps
1) Ensure the agent runtime is re-staged so `role_WireGuardTunnel.py` applies the shell firewall rule on tunnel start.
2) During an active session, run `Test-NetConnection -ComputerName 10.255.0.2 -Port 47002` on the Engine and confirm it reaches the agent.
-3) While the session is active, confirm `Agent\Borealis\Settings\WireGuard\Borealis.conf` includes a [Peer] with endpoint/AllowedIPs (it reverts to idle config after stop).
+3) Confirm `Agent\Borealis\Settings\WireGuard\Borealis.conf` includes a [Peer] with endpoint/AllowedIPs while the agent is running.
4) Capture engine and agent tunnel/shell logs around a failed shell open attempt and re-check WireGuard service state if issues persist.