From 0d40ca6edb76ff489b57767f23c5b7e80ca2010c Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 5 Feb 2026 23:05:23 -0700 Subject: [PATCH] Removed RDP in favor of VNC / Made WireGuard Tunnel Persistent --- .gitignore | 5 +- AGENTS.md | 4 +- Borealis.ps1 | 146 ++++- Data/Agent/Roles/role_RDP.py | 77 --- Data/Agent/Roles/role_VNC.py | 468 +++++++++++++++ Data/Agent/Roles/role_WireGuardTunnel.py | 185 ++++-- Data/Engine/config.py | 70 ++- Data/Engine/database_migrations.py | 7 +- Data/Engine/server.py | 22 +- Data/Engine/services/API/__init__.py | 6 +- Data/Engine/services/API/devices/routes.py | 89 +++ .../services/API/devices/{rdp.py => shell.py} | 160 +++-- Data/Engine/services/API/devices/tunnel.py | 51 +- Data/Engine/services/API/devices/vnc.py | 338 +++++++++++ .../Engine/services/RemoteDesktop/__init__.py | 3 +- .../services/RemoteDesktop/guacamole_proxy.py | 369 ------------ .../services/RemoteDesktop/vnc_proxy.py | 285 +++++++++ .../Engine/services/VPN/vpn_tunnel_service.py | 95 ++- Data/Engine/services/VPN/wireguard_server.py | 65 ++- Data/Engine/services/WebSocket/vpn_shell.py | 5 +- Data/Engine/web-interface/package.json | 2 +- .../src/Devices/Device_Details.jsx | 12 +- .../src/Devices/ReverseTunnel/Powershell.jsx | 84 +-- .../src/Devices/ReverseTunnel/RDP.jsx | 552 ------------------ .../src/Devices/ReverseTunnel/VNC.jsx | 351 +++++++++++ Data/Engine/web-interface/vite.config.mts | 9 + Docs/agent-runtime.md | 10 +- Docs/api-reference.md | 15 +- Docs/architecture-overview.md | 19 +- Docs/device-management.md | 2 +- Docs/engine-runtime.md | 11 +- Docs/index.md | 2 +- Docs/security-and-trust.md | 6 +- Docs/ui-and-notifications.md | 2 +- Docs/vpn-and-remote-access.md | 80 +-- 35 files changed, 2207 insertions(+), 1400 deletions(-) delete mode 100644 Data/Agent/Roles/role_RDP.py create mode 100644 Data/Agent/Roles/role_VNC.py rename Data/Engine/services/API/devices/{rdp.py => shell.py} (51%) create mode 100644 Data/Engine/services/API/devices/vnc.py delete mode 100644 Data/Engine/services/RemoteDesktop/guacamole_proxy.py create mode 100644 Data/Engine/services/RemoteDesktop/vnc_proxy.py delete mode 100644 Data/Engine/web-interface/src/Devices/ReverseTunnel/RDP.jsx create mode 100644 Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx 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 ( - - - - - - 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 ( + + + + + {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.