Removed RDP in favor of VNC / Made WireGuard Tunnel Persistent

This commit is contained in:
2026-02-05 23:05:23 -07:00
parent 287d3b1cf7
commit 0d40ca6edb
35 changed files with 2207 additions and 1400 deletions

5
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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
@@ -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.")
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:

View File

@@ -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

View File

@@ -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",

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
if protocol != "rdp":
return jsonify({"error": "unsupported_protocol"}), 400
try:
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
endpoint_host = _infer_endpoint_host(request)
_service_log_event(
"rdp_session_request agent_id={0} operator={1} protocol={2} remote={3}".format(
"vpn_shell_establish_request agent_id={0} operator={1} endpoint_host={2} remote={3}".format(
agent_id,
operator_id or "-",
protocol,
endpoint_host or "-",
_request_remote() or "-",
)
)
rdp_session = registry.create(
payload = tunnel_service.connect(
agent_id=agent_id,
host=host,
port=3389,
username=username,
password=password,
protocol=protocol,
operator_id=operator_id,
ignore_cert=True,
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
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}"
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
_service_log_event(
"rdp_session_ready agent_id={0} token={1} expires_at={2}".format(
"vpn_shell_disconnect_request agent_id={0} operator={1} reason={2} remote={3}".format(
agent_id,
rdp_session.token[:8],
int(rdp_session.expires_at),
operator_id or "-",
reason or "-",
_request_remote() or "-",
)
)
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"]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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,12 +70,15 @@ 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: Optional[threading.Thread] = None
if not self.persistent:
self._idle_thread = threading.Thread(target=self._idle_loop, daemon=True)
self._idle_thread.start()
@@ -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
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",
}

View File

@@ -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,7 +202,19 @@ 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})")
@@ -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,

View File

@@ -187,6 +187,9 @@ class VpnShellBridge:
existing.close()
status = service.status(agent_id)
if not status:
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)

View File

@@ -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",

View File

@@ -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,
}}
>
<ReverseTunnelRdp device={tunnelDevice} />
<ReverseTunnelVnc device={tunnelDevice} />
</Box>
);

View File

@@ -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)) {
if (data?.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;
}
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"}
</Button>
<Stack direction="row" spacing={1}>
{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) {

View File

@@ -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: <LinkIcon sx={{ fontSize: 18 }} />,
}
: null,
tunnel?.virtual_ip
? {
label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
color: MAGIC_UI.accentA,
icon: <IpIcon sx={{ fontSize: 18 }} />,
}
: null,
].filter(Boolean);
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} alignItems={{ xs: "flex-start", md: "center" }}>
<Button
size="small"
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
sx={gradientButtonSx}
disabled={loading || (!isConnected && !agentId)}
onClick={isConnected ? handleDisconnect : handleConnect}
>
{isConnected ? "Disconnect" : "Connect"}
</Button>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", alignItems: "center" }}>
<FormControl
size="small"
sx={{
minWidth: 140,
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(12,18,35,0.9)",
color: MAGIC_UI.textBright,
borderRadius: 2,
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}}
>
<InputLabel>Protocol</InputLabel>
<Select
label="Protocol"
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
disabled={isConnected}
>
{PROTOCOLS.map((item) => (
<MenuItem key={item.value} value={item.value}>
{item.label}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isConnected}
sx={{
minWidth: 180,
input: { color: MAGIC_UI.textBright },
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(12,18,35,0.9)",
borderRadius: 2,
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}}
/>
<TextField
size="small"
type="password"
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isConnected}
sx={{
minWidth: 180,
input: { color: MAGIC_UI.textBright },
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(12,18,35,0.9)",
borderRadius: 2,
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}}
/>
</Stack>
<Stack direction="row" spacing={1}>
{sessionChips.map((chip) => (
<Chip
key={chip.label}
icon={chip.icon}
label={chip.label}
sx={{
borderRadius: 999,
color: chip.color,
border: `1px solid ${MAGIC_UI.panelBorder}`,
backgroundColor: "rgba(8,12,24,0.65)",
}}
/>
))}
</Stack>
</Stack>
<Box
sx={{
flexGrow: 1,
minHeight: 320,
display: "flex",
flexDirection: "column",
borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`,
background:
"linear-gradient(145deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 20%, rgba(125,211,252,0.08), transparent 35%)",
boxShadow: "0 25px 80px rgba(2,6,23,0.85)",
overflow: "hidden",
position: "relative",
}}
>
{loading ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
<Box
ref={containerRef}
sx={{
flexGrow: 1,
position: "relative",
backgroundColor: "rgba(2,6,20,0.9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Box ref={displayRef} sx={{ width: "100%", height: "100%" }} />
{!isConnected ? (
<Stack spacing={1} sx={{ position: "absolute", alignItems: "center" }}>
<DesktopIcon sx={{ color: MAGIC_UI.accentA, fontSize: 40 }} />
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Connect to start the remote desktop session.
</Typography>
</Stack>
) : null}
</Box>
</Box>
<Stack spacing={0.3} sx={{ mt: 1 }}>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Session: {isConnected ? "Active" : sessionState}
</Typography>
{statusMessage ? (
<Typography variant="body2" sx={{ color: sessionState === "error" ? "#ff7b89" : MAGIC_UI.textMuted }}>
{statusMessage}
</Typography>
) : null}
</Stack>
</Box>
);
}

View File

@@ -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: <LinkIcon sx={{ fontSize: 18 }} />,
}
: null,
tunnel?.virtual_ip
? {
label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
color: MAGIC_UI.accentA,
icon: <IpIcon sx={{ fontSize: 18 }} />,
}
: null,
].filter(Boolean);
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} alignItems={{ xs: "flex-start", md: "center" }}>
<Button
size="small"
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
sx={gradientButtonSx}
disabled={loading || (!isConnected && !agentId)}
onClick={isConnected ? handleDisconnect : handleConnect}
>
{isConnected ? "Disconnect" : "Establish"}
</Button>
<Stack direction="row" spacing={1}>
{sessionChips.map((chip) => (
<Chip
key={chip.label}
icon={chip.icon}
label={chip.label}
sx={{
borderRadius: 999,
color: chip.color,
border: `1px solid ${MAGIC_UI.panelBorder}`,
backgroundColor: "rgba(8,12,24,0.65)",
}}
/>
))}
</Stack>
</Stack>
<Box
sx={{
flexGrow: 1,
minHeight: 320,
display: "flex",
flexDirection: "column",
borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`,
background:
"linear-gradient(145deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 20%, rgba(125,211,252,0.08), transparent 35%)",
boxShadow: "0 25px 80px rgba(2,6,23,0.85)",
overflow: "hidden",
position: "relative",
}}
>
{loading ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
<Box
ref={containerRef}
sx={{
flexGrow: 1,
position: "relative",
backgroundColor: "rgba(2,6,20,0.9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Box ref={displayRef} sx={{ width: "100%", height: "100%" }} />
{!isConnected ? (
<Stack spacing={1} sx={{ position: "absolute", alignItems: "center" }}>
<DesktopIcon sx={{ color: MAGIC_UI.accentA, fontSize: 40 }} />
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Establish to start the VNC session.
</Typography>
</Stack>
) : null}
</Box>
</Box>
<Stack spacing={0.3} sx={{ mt: 1 }}>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Session: {isConnected ? "Active" : sessionState}
</Typography>
{statusMessage ? (
<Typography variant="body2" sx={{ color: sessionState === "error" ? "#ff7b89" : MAGIC_UI.textMuted }}>
{statusMessage}
</Typography>
) : null}
</Stack>
</Box>
);
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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/<guid>` (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.

View File

@@ -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).

View File

@@ -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`.

View File

@@ -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`.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)://<engine_host>: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)://<engine_host>: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/<agent_id>` (Token Authenticated) - read allowed ports.
- `PUT /api/device/vpn_config/<agent_id>` (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 <agent_vpn_ip> -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/<agent_id>` -> read per-agent allowed ports.
- `PUT /api/device/vpn_config/<agent_id>` -> 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.